Skip to content

refactor(ui): redesign the shared key/value field across its three consumers (#37191) - #37284

Draft
adrianjm-dotCMS wants to merge 6 commits into
mainfrom
issue-37191-key-value-field-redesign
Draft

refactor(ui): redesign the shared key/value field across its three consumers (#37191)#37284
adrianjm-dotCMS wants to merge 6 commits into
mainfrom
issue-37191-key-value-field-redesign

Conversation

@adrianjm-dotCMS

Copy link
Copy Markdown
Member

Fixes #37191

Proposed Changes

Reworks the one shared Key/Value editor in libs/ui to the new design and rolls it out to all three
of its consumers — the Edit Content field, the Content Type Field Variables tab, and the
Apps custom-properties panel — so they finally look and behave the same.

Built from stock PrimeNG 21. No new component enters the workspace; the net component count for
this feature is zero.

What changed for the user

  • Values render as plain text at rest and become an input when activated — Enter commits, Escape restores.
  • Row actions (drag handle, remove) are revealed on hover instead of competing for attention on every row.
  • Hidden values move from a toggle switch in its own column to an eye inside the value field. It is a button in both states, so hiding a value is reversible — previously it was a one-way trip.
  • The hidden indicator is never hover-gated: which values are secret has to be readable at a glance.
  • Empty state gains an icon and reuses the existing copy, which already matched the design.
  • Reordering is offered in all three consumers rather than only one.

Three findings worth a reviewer's attention

  • The missing table styling was a selector problem, not a CSS one. PrimeNG themes its table with direct-child combinators (.p-datatable-tbody > tr > td). The row components used element selectors with display: contents, so the component element sat between the tbody and the tr and no table style from the theme ever applied — no borders, no header band. They now attach by attribute selector (tr[dotKeyValueTableRow]), making the host the tr itself.
  • onRowReorder must not re-apply the move. PrimeNG's onRowDrop already calls reorderArray on the array bound to [value]. Recomputing from dragIndex/dropIndex applies it twice. The handler only re-publishes the array with a fresh reference so the signal notifies.
  • Hover-hidden actions use opacity-0, never display:none or an @if. Those remove the control from the tab order and strand keyboard and touch users with no visible symptom for anyone on a mouse. A test asserts the mechanism, not merely that the icon is invisible.

Removed along the way

  • DotKeyValueUtil — 87 lines nothing imported.
  • A stale [autoFocus] binding in the Apps template, plus the spec stub that made it look real. It compiled only because strictTemplates is off in that app.
  • A duplicated copy of the row template that the tests were asserting against instead of the real one.
  • The dragAndDrop input, once reordering became universal and it had nothing left to gate.
  • Both SCSS files — styling is Tailwind against PrimeNG theme tokens, so no colour is hardcoded.

Checklist

  • Tests
  • Translations — none needed; the design's copy already existed as keyValue.value_no_rows.label
  • Security Implications Contemplated — value masking is a UI concern only, not a security boundary. It keeps a secret off a shoulder-surfer's screen; the underlying value is transmitted exactly as before. Stated in the spec so nobody mistakes it for protection.

Additional Info

Tests: 61 in libs/ui (from 31), 24 in edit-content, 70 in dotcms-ui, plus Playwright smokes for each consumer. Lint and build clean.

Not yet run: the Playwright specs have a clean typecheck but have not been executed, and the manual keyboard-only pass is still pending. Flagging it rather than implying green.

Scope: frontend only. Order persistence for Field Variables and Apps was scoped out of this issue — it would mean database, REST and encrypted-storage work.

spec.md, data-model.md and contracts/ are included as the reviewed contract; the Spec-Kit process artifacts are gitignored by design.

Screenshots

Original Updated
** original screenshot ** ** updated screenshot **

🤖 Generated with Claude Code

…nsumers (#37191)

Rework the one shared Key/Value editor in `libs/ui` to the new design and roll it
out to all three of its consumers: the Edit Content field, the Content Type Field
Variables tab, and the Apps custom-properties panel.

The redesign is assembled from stock PrimeNG 21 — no new component enters the
workspace, and the net component count for the feature is zero.

What changed for the user:

- Values render as plain text at rest and become an input when activated; Enter
  commits, Escape restores.
- Row actions (drag handle, remove) are revealed on hover instead of competing for
  attention on every row.
- Hidden values move from a toggle switch in its own column to an eye inside the
  value field. It is a button in both states, so hiding a value is reversible.
- The hidden indicator is never hover-gated: which values are secret has to be
  readable at a glance.
- Empty state gains an icon and reuses the existing `keyValue.value_no_rows.label`
  copy, which already matched the design.
- Reordering is offered in all three consumers rather than only one.

Implementation notes worth knowing:

- The row components attach by attribute selector (`tr[dotKeyValueTableRow]`), so
  their host IS the `tr`. PrimeNG themes its table with direct-child combinators
  (`.p-datatable-tbody > tr > td`), and an element wrapper between the tbody and
  the tr silently defeats every table style the theme provides. This is why the
  previous markup rendered with no borders or header band.
- `onRowReorder` must not re-apply the move: PrimeNG's `onRowDrop` already calls
  `reorderArray` on the array bound to `[value]`. The handler only re-publishes it
  with a fresh reference so the signal notifies.
- Hover-hidden actions use `opacity-0`, never `display:none` or an `@if` — those
  remove the control from the tab order and strand keyboard and touch users. A
  test asserts the mechanism, not merely that the icon is invisible.

Also removed along the way: `DotKeyValueUtil` (87 lines nothing imported), a stale
`[autoFocus]` binding in the Apps template together with the spec stub that made it
look real, a duplicated copy of the row template that tests were asserting against
instead of the real one, the now-unused `dragAndDrop` input, and both SCSS files.

Icons follow the repo standard (Material Symbols); PrimeIcons are gone from this
component's authored markup. No new i18n key was needed.

Tests: 61 in `libs/ui` (from 31), 24 in `edit-content`, 70 in `dotcms-ui`, plus
Playwright smokes for each consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @adrianjm-dotCMS's task in 6m 44s —— View job


PR Review — Key/Value field redesign

Reviewed the full diff against the merge base. The refactor is well-constructed: the attribute-selector tr hosts, the onRowReorder "don't re-apply the move" reasoning, and the opacity-0 (not @if/display:none) hover reveal are all correct, and the parsePreservingKeyOrder regex is genuinely safe — inside a JSON string every quote is \", so "(\d+)": can only ever match a real key, never a value. I verified all | dm keys (Reorder, Delete, add, keyValue.action.load_more, the header labels) exist in Language.properties, and that no dangling references to the removed DotKeyValueUtil / dragAndDrop / autoFocus / DotContentletService remain.

A few things worth a look before merge — none are provable defects, so all Medium:

New Issues

  • 🟡 Medium: libs/edit-content/src/lib/fields/dot-edit-content-key-value/components/key-value-field/key-value-field.component.ts:53The KEY_VALUE form value type changed from an object to a JSON string. updateField now calls onChange('{"k":"v"}') where it previously emitted {k:v}. Because KEY_VALUE is in UNCASTED_FIELD_TYPES (functions.util.ts:191), this value is sent to the backend verbatim on save — so the request body field goes from an object to a JSON-encoded string.

    • Assumption: the content-save REST endpoint accepts a JSON string for a Key/Value field and stores it as an ordered map (this is what the PR claims and is consistent with dotCMS historically storing KV as JSON).
    • What to verify: a real create → save → reload → save-untouched round-trip actually persists and re-reads correctly, including a numeric-key case to confirm order survives end to end. The PR explicitly states the Playwright specs were not executed, so this contract is currently unverified by any run. This is the single highest-value thing to confirm before merge.
  • 🟡 Medium: libs/ui/src/lib/components/dot-key-value-ng/dot-key-value-ng.component.ts:82$visibleCount resets to PAGE_SIZE on every list mutation in consumers that reassign the array bound to [variables]. $visibleCount is a linkedSignal whose source is $variables; Apps (dot-apps-configuration-detail.component.ts:92, this.dynamicVariables = [...variables]) and Field Variables re-publish the input on every add/edit/delete/reorder, which re-seeds the count. So a user who clicks Load more to reveal 80 rows and then edits or reorders one collapses back to 40 visible. Only bites lists >40 rows; Edit Content is unaffected (it writes to the form control, not the [variables] input). Consider seeding $visibleCount so it grows-only / isn't reset by same-content re-feeds. Fix this →

  • 🟡 Medium: libs/ui/src/lib/components/dot-key-value-ng/dot-key-value-table-row/dot-key-value-table-row.component.html:6The drag handle is keyboard-focusable but not keyboard-operable. It carries role="button" + tabindex="0" and reveals on focus, but pReorderableRowHandle only wires pointer drag — there's no keydown handler to move a row. Keyboard-only users can tab to it and get nothing. Given the PR's stated a11y goal (the opacity-0-not-@if decision was made precisely so keyboard/touch users keep the actions), a focusable no-op handle is inconsistent. Either wire arrow-key reorder or drop tabindex="0"/role="button" from the handle so it isn't advertised as an actionable control. This overlaps the still-pending "manual keyboard-only pass" the PR flagged.

Notes (non-blocking)

  • The PR description says hiding a value is now "reversible … a button in both states," but the code comment at dot-key-value-table-row.component.html:24 correctly states an existing row carries no visibility control — the eye toggle lives only on the new-pair entry row. The code is fine; the description overstates it.
  • dot-edit-content-side-panel.component.html:37 (text-lgtext-2xl) looks unrelated to the Key/Value redesign — harmless, just flagging the scope.

The util-level tests (key-value-order.util.spec.ts) and resolver test additions give good coverage of the ordering logic. The main gap is that the persistence contract (finding 1) has no executed end-to-end assertion yet.
· issue-37191-key-value-field-redesign

…coverage

This update refines the key/value field component across various consumers, ensuring consistent behavior and improved user experience. Key changes include:

- Implemented a new method for closing the field dialog using the Escape key, enhancing accessibility.
- Updated tests to utilize unique keys for entries, preventing collisions and ensuring reliable test outcomes.
- Enhanced the visibility control for hidden values, making it clearer when values are masked.
- Introduced pagination for long lists in the key/value component, allowing users to load more entries as needed.

These changes aim to streamline interactions and maintain data integrity across the application.
erickgonzalez
erickgonzalez previously approved these changes Aug 31, 2026

@erickgonzalez erickgonzalez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec looks good! Let's continue with the implementation.

Recovering a Key/Value field's key order picked its target by shape — "a plain
object whose values are all primitives". A binary field's `metaData` is exactly
that, so it was rewritten into JSON text, `contentlet.metaData.name` stopped
being readable, and the file preview came back empty after a reload
(`binary-field.spec.ts`, CI). `getContentById` serves every field type, so a
guess there reaches all five of its callers.

The decision moves to the one place that knows a field's type: the KEY_VALUE
entry in the form's resolution map. `getContentById` now hands back the
contentlet untouched and parks the order-preserving parse alongside it, under a
key of its own; the resolver reads its own variable out of that and nothing else
can be mistaken for it.

Also fixes two E2E specs that only passed on a machine seeded a particular way:

- Apps custom properties drove SSO — SAML, which requires nine parameters
  including certificates. Save is bound to the whole form's validity, so it is
  disabled on a fresh instance. Now uses an app that requires one string, filled
  by the spec, and waits for the navigation a save triggers instead of racing it.
- The field dialog was closed with Escape, which depends on where focus is. Uses
  the dialog's own close button, which is always present.
The Field Variables persistence check closed the dialog to reopen it. In CI the
close button was found, visible and stable, but every click was intercepted by a
modal mask sitting above it, so the test spent its whole timeout retrying.

Reloading the builder gets at what the test is actually for — that each row was
written against the field rather than held in the dialog's state — and does it
more strictly, since nothing at all survives a reload. It also has no modal in
its way.

`closeFieldDialog` had no other caller and could not be relied on, so it goes
rather than sitting there for someone to reach for.
The previous commit meant to reload but called `goToBuilder`, which navigates to
the URL already in the address bar. That is a same-document fragment navigation:
nothing is torn down, so the field dialog stayed open and its header intercepted
the click meant for the field underneath.

`reloadBuilder` does a real reload and waits for the builder to come back. With
the local instance up again this reproduced the CI failure exactly, and the four
specs now pass twice over.
The Field Variables persistence check reloaded straight after deleting a row. On
screen the row was already gone — the shared editor drops it from its own list on
click, while the consumer only commits once the request returns — so
`expectKeyAbsent` passed while the DELETE was still in flight. The reload
overtook it and the row came back, failing in CI and passing here.

Each mutation now waits for the response from the per-row endpoint and asserts it
succeeded, so the reload can only happen after the write has landed.

Verified by holding that endpoint for 3s locally, which reproduces CI: without
the wait the row is back after the reload, with it the delete survives.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Refactor shared Key/Value field to new design across edit content, field variables, and apps portlet

2 participants