feat(frontend): Sheet primitive, migrate legacy drawers, standardize resource-list UX by d-bytebase · Pull Request #19962 · bytebase/bytebase · GitHub
Skip to content

feat(frontend): Sheet primitive, migrate legacy drawers, standardize resource-list UX - #19962

Merged
d-bytebase merged 11 commits into
mainfrom
feat/sheet-primitive-and-drawer-cleanup
Apr 10, 2026
Merged

d-bytebase merged 11 commits into
mainfrom
feat/sheet-primitive-and-drawer-cleanup

Conversation

@d-bytebase

Copy link
Copy Markdown
Collaborator

Summary

A consolidated consistency + bugfix pass over the React resource-list pages, with three main components:

  1. New Sheet primitive in frontend/src/react/components/ui/sheet.tsx — shadcn-style wrapper around Base UI Dialog, right-side layout, three width tiers (narrow / standard / wide) via cva, built-in top-right close button, automatic portal + focus trap + Escape + outside-click via Base UI.
  2. Legacy drawer + wrong-dialog cleanup — ~18 components renamed *Drawer → *Sheet and migrated from hand-rolled fixed inset-y-0 right-0 markup (or centered <Dialog>) to the primitive. Each hand-rolled drawer was reimplementing the same pattern with slightly different widths, backdrops, escape hooks, and close buttons — now they all share one primitive.
  3. Resource-list UX standardization — the Workload Identities / Users / Groups / Service Accounts list pages all had inconsistent entry points to the detail drawer (some "Edit" pencil, some "View" eye, some both). Replaced with a consistent row-click → detail Sheet pattern across all four pages. Operations column now holds only destructive/secondary actions.

Plus two hard-to-find bug fixes uncovered during the migration:

  • Workload Identity edit fields were empty: WorkloadIdentitiesPage.handleUserSelected called the sync getWorkloadIdentity(email) which returns a stub (no workloadIdentityConfig) when the cache is empty — and the list fetch never populated the cache. Fixed by switching to async getOrFetchWorkloadIdentity.
  • Edit sheets showed stale/empty state across opens: the always-mounted <Sheet open={open}> pattern means useState initializers only run once. Fixed by introducing an outer wrapper + inner form + key pattern across all edit sheets, guaranteeing the inner form mounts fresh on every open with up-to-date props.

Also: dirty tracking added to all edit sheets — the Update button is disabled unless at least one field actually differs from its initial value.

What's in this PR

New primitive

  • frontend/src/react/components/ui/sheet.tsx — Sheet, SheetTrigger, SheetContent (with width variant: narrow 384px / standard 704px / wide 832px), SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, SheetClose, SheetOverlay. Built-in close button in top-right. z-50 per the overlay-stacking rule in frontend/AGENTS.md.

Wrong-Dialog migrations (complex forms that shouldn't have been dialogs)

  • CustomApproval/RuleEditDialog.tsx → RuleEditSheet.tsx — 5-section form with CEL builder + approval nodes table. wide.
  • MembersPage.tsx RequestRoleDialog → RequestRoleSheet — 7-section form with role picker + CEL database scope + environment scope + expiration + labels. standard.
  • CreateRevisionDialog.tsx → CreateRevisionSheet.tsx — 3-field form. narrow. Orphan (not yet wired up), migrated for completeness.

Hand-rolled drawer → primitive migrations (same behavior, just use the primitive)

Standalone files, all renamed *Drawer → *Sheet:

  • components/EditEnvironmentDrawer.tsx (narrow)
  • components/CreateWorkloadIdentityDrawer.tsx (standard)
  • components/database/LabelEditorDrawer.tsx (narrow)
  • components/database/TransferProjectDrawer.tsx (standard)
  • components/database/CreateDatabaseDrawer.tsx (standard)
  • pages/settings/shared/AADSyncDrawer.tsx (standard)
  • pages/project/export-center/DataExportPrepDrawer.tsx (wide, multi-step wizard)

Inline-in-page drawers, same *Drawer → *Sheet rename:

  • EnvironmentsPage.tsx — CreateDrawer (standard) + ReorderDrawer (narrow)
  • UsersPage.tsx — CreateUserDrawer (standard)
  • ServiceAccountsPage.tsx — CreateServiceAccountDrawer (standard)
  • GroupsPage.tsx — CreateGroupDrawer (standard)
  • ProjectsPage.tsx — CreateProjectDrawer (standard)
  • RolesPage.tsx — RoleDrawer (standard)
  • InstancesPage.tsx — inline EditEnvironmentDrawer (narrow)
  • LandingPage.tsx — ConfigDrawer (narrow)

Edit-sheet infrastructure fixes

The always-mounted Sheet pattern broke useState initialization on re-open. Fixed using the outer-wrapper + inner-form + key pattern across all edit sheets:

function CreateFooSheet(props) {
  const { open, foo, onClose } = props;
  return (
    <Sheet open={open} onOpenChange={(next) => !next && onClose()}>
      <SheetContent width="standard">
        {open && <FooForm key={foo?.name ?? "new"} {...props} />}
      </SheetContent>
    </Sheet>
  );
}

{open && ...} unmounts inner form on close, key forces remount when the edited entity changes, and useState initializers in the inner form read directly from the entity prop — always fresh.

Dirty tracking added to all edit sheets: const isDirty = useMemo(...) comparing current state to captured initials, allowConfirm = isFormValid && isDirty. Update button disabled until the user actually changes something.

Applied to: CreateWorkloadIdentitySheet, CreateUserSheet, CreateServiceAccountSheet, CreateGroupSheet.

Workload Identity stub fix

WorkloadIdentitiesPage.handleUserSelected now uses getOrFetchWorkloadIdentity (async) instead of the sync getWorkloadIdentity which returned a stub without workloadIdentityConfig when the cache was cold. Without this fix, clicking Edit on a row left Organization / Repository / Branch empty.

Resource-list row-click UX (WorkloadIdentities / Users / Groups / ServiceAccounts)

Before: each row had an "Edit" pencil (or "View" eye) icon + Delete. Inconsistent across pages — some used pencil, some used eye, for the exact same interaction.

After: the whole row is clickable to open the detail Sheet. Operations column holds only destructive/secondary actions (Delete, Restore) with e.stopPropagation() on each handler so they don't also trigger row-open. Entity title no longer has the blue text-accent "primary link" color — the row hover state is the clickability signal.

The Sheet adapts view-vs-edit based on permissions (inputs already disable when the user lacks update permission). No separate "view" or "edit" label that would be a half-truth on pages where some rows are editable and others are read-only.

frontend/AGENTS.md guideline update

New section under ## shadcn Component Guidelines → Rules:

  • When to use <Sheet> vs <Dialog> vs <AlertDialog>
  • Sheet width tiers with concrete resource examples
  • The outer-wrapper + inner-form + key pattern for edit sheets (with code sample)
  • Dirty-tracking rule for edit sheets
  • The fetch-full-entity-before-open rule (with the stub-WI bug as the cautionary tale)

DropdownMenu primitive side-effect

Unrelated but required for row-click to work without clipping — this branch also ships a row-action dropdown clipping fix that was already shipped in #19960 (that's merged on main). No duplication.

UX Changes (not breaking per AGENTS.md §Breaking Change Check, but worth calling out)

  • Custom Approval Rule editor — centered dialog → right-side wide drawer. Same fields, same save flow.
  • Request Role dialog — centered dialog → right-side standard drawer. Same fields.
  • Workload Identities / Users / Groups / Service Accounts list rows — click the row (not an icon) to open detail. Delete stays in Operations column. Affects how power-users muscle-memory access detail views, but it's strictly an improvement: consistent across pages, one less click target, whole row is clickable.

Test plan

  • pnpm --dir frontend type-check passes
  • pnpm --dir frontend check (ESLint + Biome + i18n + locale sort) passes
  • pnpm --dir frontend test — all 1031 unit tests pass
  • Visual QA: Sheet primitive basics — open any migrated sheet (e.g. Custom Approval → Create rule), verify: slide-in animation from right, backdrop dims the page but list remains visible, top-right X close button, Escape dismisses, clicking backdrop dismisses, focus is trapped inside the Sheet
  • Visual QA: Edit sheet populates correctly — Settings → Workload Identities, click any row, verify Name / Email / Platform / Organization / Repository / Branch all populate from the saved identity. Then close, click a different row, verify fields repopulate from the new entity (not the previous one)
  • Visual QA: Same for Users / Service Accounts / Groups — click a row, verify all fields populate; Update button is disabled until you change something; change a field and Update enables
  • Visual QA: Row-click affordance — hover over a row in Workload Identities / Users / Groups / Service Accounts, verify cursor changes to pointer and row gets a subtle hover background. Click the row → detail Sheet opens. Click the Delete icon specifically → delete confirm fires without also opening detail
  • Visual QA: Group expand/collapse — the chevron on GroupRow still toggles expand/collapse without opening the detail Sheet
  • Visual QA: Workload Identity save round-trip — edit a saved identity, change the branch, click Update, reopen the same identity, verify the new branch is persisted and populates
  • Visual QA: Base UI overlays stack correctly — open a Sheet, then open a Select inside the Sheet (e.g. the Platform picker in Workload Identity) — the Select should render above the Sheet backdrop, not behind it (BYT-9226 regression check)
  • Visual QA: Data export wizard — Settings → Export Data → Create; multi-step wizard in wide drawer; step indicator in header, form in body, Back/Next/Create in footer
  • Visual QA: All other migrated sheets smoke-test — each migrated *Sheet should open, show its form, accept input, and submit
  • CI checks: Go build, frontend build, SonarCloud

Follow-ups (intentionally out of scope)

  • TableDetailDialog (frontend/src/react/pages/project/database-detail/overview/TableDetailDialog.tsx, 1341 lines) — complex table metadata editor with nested ClassificationPickerDialog and SemanticTypePickerDialog. Deserves its own PR to decide whether the nested pickers should remain centered dialogs on top of the Sheet, or become in-Sheet popovers.
  • SQL Review RuleEditDialog (sql-review/RuleComponents.tsx:359) — kept as a Dialog. It's small enough (level switch + 2–6 rule-specific payload fields) that centering is fine, and migration would be pure churn.
  • ProjectMaskingExemptionCreatePage — not a dialog; it's a full route page. Excluded.
  • Read-only mode refinement in Sheets — when a user lacks update permission, the Sheet inputs already disable (inherited behavior from each form's existing permission checks). A dedicated readOnly prop + hiding the Save button entirely is a nice follow-up but not required for the row-click UX change to work.
  • Guideline doc enforcement — added the rule to frontend/AGENTS.md; a future lint rule or codemod to flag direct fixed inset-y-0 right-0 hand-rolled drawers would be nice.

🤖 Generated with Claude Code

d-bytebase and others added 7 commits April 10, 2026 15:01
…right-side drawer

The frontend has no Sheet/Drawer primitive in shadcn `ui/`, so every existing
"drawer" (RoleDrawer, EditEnvironmentDrawer) is hand-rolled with
`fixed inset-0 z-50` + a manual backdrop, manual escape handling, and an
inlined one-off width. That's the same missing-primitive pattern we just
fixed for DropdownMenu — different call sites, same drift (different widths,
different backdrop tokens, different escape hooks).

Meanwhile, multiple complex resource-edit forms are still centered dialogs
where a side drawer fits far better — Custom Approval Rule is the clearest
example, with presets + title + description + a CEL expression builder + a
draggable approval-nodes table all crammed into a modal that was already
sized `lg:max-w-[75vw] 2xl:max-w-[55vw]` just to fit the content.

This PR builds the missing primitive and uses Custom Approval Rule as the
reference implementation that proves it works end-to-end.

- Add `frontend/src/react/components/ui/sheet.tsx` wrapping Base UI's Dialog
  primitive (same `@base-ui/react/dialog` powering `dialog.tsx`) with a
  right-side layout. Focus trap, Escape, outside-click, ARIA, and portal all
  come free from Base UI — no more hand-rolled hooks.
- Codify three width tiers via `cva` on `SheetContent`:
    narrow   (w-[24rem], 384px) — single-field pickers, short forms
    standard (w-[44rem], 704px) — 3-6 field forms, permission transfer
    wide     (w-[52rem], 832px) — CEL builders, nested tables, multi-tab
  New consumers must pick a tier — no more ad-hoc widths.
- Match the overlay-stacking rule in `frontend/AGENTS.md`: backdrop and popup
  both at `z-50`, matching Dialog/Select/Tooltip/DropdownMenu so later-mounted
  portals (e.g. a Select opened *inside* a Sheet) correctly render on top.
- Slide-in transition keyed off Base UI's `data-starting-style` /
  `data-ending-style` data attributes.
- Export `Sheet`, `SheetTrigger`, `SheetContent`, `SheetHeader`, `SheetBody`,
  `SheetFooter`, `SheetTitle`, `SheetDescription`, `SheetClose`, `SheetOverlay`.

Migration:
- Rename `RuleEditDialog.tsx` → `RuleEditSheet.tsx` and component
  `RuleEditDialog` → `RuleEditSheet`.
- Replace Dialog/DialogContent/DialogTitle with
  Sheet/SheetContent width="wide"/SheetHeader/SheetBody/SheetFooter/SheetTitle.
- Drop the hand-rolled `flex flex-col overflow-hidden lg:max-w-[75vw]` and
  header/body/footer div chrome — that's now the primitive's job.
- Update the single importer in `RulesSection.tsx`.

All checks green: type-check, ESLint + Biome, React i18n, 1031 unit tests,
dev server compiles and serves HTTP 200.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rimitive

Replaces the hand-rolled `fixed inset-0 z-50` drawer markup in 7 standalone
files with the shadcn Sheet primitive, renaming each `*Drawer` to `*Sheet`
to match the shadcn naming convention. Also extends the Sheet primitive with
a built-in close button (top-right corner, absolute positioned) so consumers
no longer need to render their own X close button.

Migrated files (all renamed *Drawer → *Sheet):
- components/EditEnvironmentDrawer.tsx (narrow) — single environment picker
- components/CreateWorkloadIdentityDrawer.tsx (standard) — conditional provider fields
- components/database/LabelEditorDrawer.tsx (narrow) — bulk database labels editor
- components/database/TransferProjectDrawer.tsx (standard) — project picker w/ search
- components/database/CreateDatabaseDrawer.tsx (standard) — engine-conditional form
- pages/settings/shared/AADSyncDrawer.tsx (standard) — SCIM config display
- pages/project/export-center/DataExportPrepDrawer.tsx (wide) — multi-step export wizard

Importers updated: ProjectDatabasesPage, DatabasesPage (settings),
ProjectDataExportPage, ProjectGitOpsPage, WorkloadIdentitiesPage,
GroupsPage, UsersPage, ProjectDatabaseDetailPage (+ its test mock).

Also: Sheet primitive now includes a `<Dialog.Close>` in SheetContent that
renders an absolute-positioned X button in the top-right corner. SheetHeader
now reserves right-padding (`pr-12`) to avoid overlap with the close button.

Type-check passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ix edit-mode regression

Migrates the inline drawer components in 6 page files (EnvironmentsPage
CreateDrawer/ReorderDrawer, UsersPage CreateUserDrawer, ServiceAccountsPage
CreateServiceAccountDrawer, GroupsPage CreateGroupDrawer, LandingPage
ConfigDrawer, CreateWorkloadIdentitySheet standalone) from hand-rolled
`fixed inset-y-0 right-0` markup to the Sheet primitive. All renamed
locally (*Drawer → *Sheet) to match the shadcn naming convention.

**Fixes a regression introduced by the Sheet migration pattern**:

The original `{showX && <Drawer .../>}` pattern caused the drawer to
*remount* on every open, which re-ran every `useState(user?.title ?? "")`
initializer against the latest `user` prop — so editing an existing row
showed its populated fields. The new `<Sheet open={showX}/>` pattern keeps
the Sheet mounted across opens so those initializers only ran *once*
(during first mount, when `user` was undefined). Result: clicking View /
Edit on an existing user / group / service account / workload identity
opened the Sheet with *empty* fields instead of populated ones.

Fix: add an explicit `useEffect(() => { if (open) reset from props }, [open, entity])`
to each affected Sheet so state is repopulated every time the Sheet is
opened — whether in create mode (resets to defaults) or edit mode (copies
from the passed entity). Applied to:

- UsersPage.tsx / CreateUserSheet
- ServiceAccountsPage.tsx / CreateServiceAccountSheet
- GroupsPage.tsx / CreateGroupSheet
- CreateWorkloadIdentitySheet.tsx (latent bug — existing useEffect ran
  only on mount)
- EnvironmentsPage.tsx / CreateSheet (reset defaults on reopen)
- EnvironmentsPage.tsx / ReorderSheet (reset list from props on reopen)

Call sites also updated from `{showX && <Drawer/>}` to `<Sheet open={showX}/>`
for consistency, which is what revealed the regression in the first place.

Reported by the user after clicking "View" on a user row in Users page —
screenshot confirmed empty fields. Same pattern applied to Service Accounts.

Type-check passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ers to Sheet primitive

- ProjectsPage CreateProjectDrawer → CreateProjectSheet (standard width)
- InstancesPage inline EditEnvironmentDrawer → EditEnvironmentSheet (narrow)
- RolesPage RoleDrawer → RoleSheet (standard width)

All use the Sheet primitive's width tiers instead of hand-rolled `fixed inset-y-0 right-0` panels, and remove the custom X close button (Sheet provides one in the top-right automatically). The `{open && ...}` conditional renders are replaced with `<Sheet open={open}/>`.

The RoleSheet previously hid itself via `if (!role) return null`; now it uses `open={detailRole !== undefined}` passed from the parent. The existing `useEffect([role])` that syncs the incoming role prop to local state continues to handle edit-mode population.

Type-check passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rty tracking

The earlier `useEffect([open, entity])` reset pattern was fragile:

1. **Workload Identity** has two chained `useEffect` blocks that sync fields
   ↔ subject pattern bidirectionally via ref guards. The reset effect set
   owner/repo/branch, but the sync effects then re-ran with stale refs and
   sometimes clobbered them — leaving Organization / Repository / Branch
   empty in the screenshot the user sent.

2. For **User / Service Account / Group**, the reset effect worked in
   principle but was noisy (lots of setXxx calls inside a useEffect) and
   brittle to add dirty tracking on top of (no clean baseline to compare
   against).

Switch all four edit sheets to the **outer wrapper + inner form + key**
pattern:

    <Sheet open={open}>
      <SheetContent>
        {open && <InnerForm key={entity?.name ?? 'new'} {...props} />}
      </SheetContent>
    </Sheet>

- Outer `CreateXxxSheet` is a thin wrapper that only renders the Sheet.
- Inner `XxxForm` is mounted fresh every time the Sheet opens, because
  `{open && ...}` unmounts it on close and the `key` forces a remount when
  the entity changes.
- useState initializers in InnerForm read directly from the entity prop —
  they always see the latest values because the component is freshly
  mounted.
- No reset effect needed. No ref-guarded sync loops. No stale state bleed
  between opens.

Also adds **dirty tracking** to the Update button: in edit mode, the button
is disabled unless at least one field differs from its initial value. In
create mode, the button is always allowed (validation still applies).

Captures initial values at mount and computes `isDirty` via `useMemo`.
`allowConfirm = isFormValid && isDirty`.

Applied to:
- CreateWorkloadIdentitySheet — owner/repo/branch now populate from
  parseWorkloadIdentitySubjectPattern reliably; Update button disabled
  until something changes.
- CreateUserSheet — title/email/phone/password/roles all populate; Update
  disabled until field edit or password set.
- CreateServiceAccountSheet — title populates; Update disabled until
  title edit.
- CreateGroupSheet — title/description/members populate; existing
  `hasChanged` kept; Update disabled until change.

Reported by the user after testing Workload Identity edit and Service
Account edit on the branch. Type-check passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…stRoleDialog to Sheet

## Workload Identity edit: Organization / Repository / Branch were empty

Root cause: `WorkloadIdentitiesPage.handleUserSelected` called the
synchronous `workloadIdentityStore.getWorkloadIdentity(user.email)`, which
returns a **stub** WorkloadIdentity (just name + email + title + state)
when the cache is empty. The list-fetch path doesn't populate the cache,
so every "Edit" click on a row returned a stub — no `workloadIdentityConfig`,
no `subjectPattern`, so `parseWorkloadIdentitySubjectPattern` returned
undefined and the inner form's `initialOwner/initialRepo/initialBranch`
all defaulted to "".

Downstream, the dirty-tracking logic correctly identified the form as
"not dirty" (state matched initial values), which is *why* the Update
button was also grayed out — the state was self-consistent but based on
an empty baseline.

Fix: swap `getWorkloadIdentity` for `getOrFetchWorkloadIdentity` and make
`handleUserSelected` async, so the full WorkloadIdentity (with config) is
loaded before `setEditingWI` is called. `ProjectGitOpsPage` already had a
prior fetch-into-cache workaround in its display path (see line 148), but
the edit flow in `WorkloadIdentitiesPage` was missing the equivalent fetch.

## RequestRoleDialog → RequestRoleSheet

Original motivator for this whole thread — the user's first screenshot was
the Custom Approval Rule dialog, then the same argument was extended to
other multi-field edit forms including RequestRole. `RequestRoleDialog`
was a `<Dialog>` containing 7+ conditionally-rendered sections (role
picker, reason textarea, database scope with ALL/SELECT/EXPRESSION mode +
CEL builder, environment picker, expiration picker, issue labels). Too
complex for a centered modal — converted to `<Sheet width="standard">`
using the same outer-wrapper + inner-form + key pattern as the other
edit sheets, to guarantee fresh state per open.

The Vue Dialog imports are now unused in `MembersPage.tsx` and were removed.

Type-check passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ateRevisionSheet

## Row-click opens detail (standardize across resource list pages)

Replaces the inconsistent mix of "Edit" (pencil) and "View" (eye) icon
buttons in the Operations column with a single pattern:

- The whole **row is clickable** to open the detail Sheet
- Row gets `cursor-pointer hover:bg-control-bg` as the affordance
- Operations column holds only **destructive/secondary actions** (Delete,
  Restore) — each with `e.stopPropagation()` so they don't also trigger
  row-open
- The "primary accent" color (`text-accent`) on the entity title is
  dropped in favor of `text-main` — the row hover state is the
  clickability signal, not a blue title

Whether the Sheet lets you edit or only view is a function of the user's
permissions (the form already disables inputs / hides Save when the user
lacks update permission). The entry point no longer promises "view" or
"edit" — both labels were half-truths on pages where a single row might
be editable for some users and read-only for others.

Applied to:
- `WorkloadIdentitiesPage` — removed Pencil edit button
- `UsersPage` — removed Eye/Pencil icon (whichever was shown per account
  type), kept Delete
- `GroupsPage` — removed Pencil edit button from GroupRow, kept Delete;
  the expand/collapse chevron button gets `e.stopPropagation()` so it
  toggles without also opening detail
- `ServiceAccountsPage` — removed Pencil edit button, kept Delete

## frontend/AGENTS.md: new Dialog vs Sheet guideline

Added a new section under `## shadcn Component Guidelines → Rules`
covering:

- When to use `<Sheet>` vs `<Dialog>` vs `<AlertDialog>` (creating/editing
  → Sheet; confirmation/prompt/read-only result → Dialog)
- Sheet width tiers (narrow / standard / wide) with concrete examples of
  when to pick each
- The outer-wrapper + inner-form + `key` pattern for edit sheets, with a
  code example, so future edit sheets populate reliably on every open
- The dirty-tracking rule (`allowConfirm = isFormValid && isDirty`) for
  edit sheets
- The fetch-full-entity-before-open rule to avoid the stub WI bug

## CreateRevisionDialog → CreateRevisionSheet

Complete the wrong-Dialog audit by migrating the last flagged offender.
Currently orphaned (not yet imported by DatabaseRevisionPanel which still
uses the Vue version), but in-place and ready for when that panel migrates.

Lint, type-check, 1031 unit tests all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@d-bytebase
d-bytebase requested a review from a team as a code owner April 10, 2026 23:06
@cla-bot cla-bot Bot added the cla-signed label Apr 10, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66607a3c6a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread frontend/src/react/pages/settings/UsersPage.tsx
Comment thread frontend/src/react/pages/settings/WorkloadIdentitiesPage.tsx
d-bytebase and others added 4 commits April 10, 2026 16:24
… primitive polish

Follow-up fixes from the self-review on #19962:

## Row-click keyboard accessibility (blocking a11y regression)

The new row-click → detail Sheet pattern in WorkloadIdentities / Users /
Groups / ServiceAccounts had no keyboard support — mouse users could click
the row to open the sheet but keyboard users had no way in. The old
Pencil/Eye `<Button>` handled this for free; the refactor dropped it.

Added to each clickable row:
- `tabIndex={0}` when the row is openable
- `role="button"` so screen readers announce it as actionable
- `aria-label` set to the entity's display title
- `onKeyDown` handling Enter and Space (with `preventDefault` on Space to
  avoid page scroll)
- `focus-visible:bg-control-bg` so the focused row has a visible outline

## Blank sheet during close animation (cosmetic bug)

The `{open && <InnerForm ... />}` guard inside `SheetContent` unmounted
the inner form the instant `open` flipped to false — but Base UI's Dialog
Portal keeps the Popup in the DOM for ~200ms during the close transition,
so users saw a blank sheet sliding off-screen.

Replaced with a **stable-entity ref** pattern in all five edit sheet
wrappers (CreateWorkloadIdentitySheet, CreateUserSheet,
CreateServiceAccountSheet, CreateGroupSheet, plus RequestRoleSheet for
consistency):

```tsx
const openEntityRef = useRef(user);
if (open) {
  openEntityRef.current = user;
}
const stableUser = openEntityRef.current;

return (
  <Sheet open={open} ...>
    <SheetContent>
      <UserForm key={stableUser?.name ?? "new"} user={stableUser} ... />
    </SheetContent>
  </Sheet>
);
```

The ref freezes the last-open entity while `open === false`, so the form
continues to render the previous entity's data through the close
animation. Base UI's Dialog.Portal unmounts the Popup after the animation
completes, at which point the form unmounts with it; a subsequent open
remounts it fresh (with useState initializers reading the then-current
prop).

The `{open && ...}` guard is gone — Base UI's natural
mount/unmount-around-animation handles lifecycle correctly.

## SheetHeader / SheetContent layout fragility

The previous design had the close button absolute-positioned at
`top-4 right-4` of `SheetContent`, with `SheetHeader` padding-compensating
via `pr-12` to avoid visual overlap. Two problems:

1. Callers that override `SheetHeader`'s `className` could clobber the
   `pr-12` and cause the close button to overlap the header content.
2. The close button's position and the header's padding had to stay in
   sync in two separate places.

Refactored so the close button lives **inside** `SheetHeader` as a flex
child, with the title/description in a `flex-col` on the left and the
button flush right. `SheetContent` no longer renders the button itself.
This means consumers without a `SheetHeader` don't get a close button —
but all current consumers use one, and the pattern is the same one
shadcn's sheet.tsx ships with.

## Orphan file deletion

`CreateRevisionSheet.tsx` was noted in the PR description as "not yet
wired up" — the Vue version is still in use via DatabaseRevisionPanel.
Carrying dead code is noise. Deleted the file and removed the 5 now-unused
i18n keys (`database.revision.import-revision`,
`database.revision.invalid-version-format`,
`database.revision.type-declarative`, `database.revision.type-versioned`,
`database.revision.version-already-exists`) from all 5 locale files. When
DatabaseRevisionPanel migrates to React, the component can be re-added.

## AGENTS.md guideline correction

The code sample in the new Dialog-vs-Sheet guideline used the old
`{open && <Form />}` pattern. Updated to show the stable-entity ref
pattern that the actual code now uses, with comments explaining the
close-animation reasoning. Future contributors reading the doc get the
right pattern on first try.

## Minor: comment on initialRoles mount-only capture

The `initialRoles` useMemo in `UsersPage.tsx` has an empty deps array
intentionally — we want the baseline frozen at mount so dirty tracking
compares against a stable reference. Added a comment explaining the
rationale so a future reader doesn't "fix" it by adding `userMapToRoles`
to deps (which would clobber the baseline on async store updates).

Type-check, lint, and all 1031 unit tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…r drag

## Codex P2 #1: User row click hijacks group badge navigation

Clicking a group badge inside `UserGroupsCell` bubbled up to the new
`<TableRow onClick>` handler, which opened the user detail sheet *and*
navigated to the group — producing two competing route pushes.

Added `e.stopPropagation()` in the badge's onClick so badge clicks only
navigate to the group, as intended. Matches the pattern already used on
Delete/Restore buttons in the same rows.

## Codex P2 #2: Stale async selection race in WorkloadIdentitiesPage

`handleUserSelected` awaits `getOrFetchWorkloadIdentity` before calling
`setEditingWI`. If a user clicks two different rows in quick succession
on a cold cache, the slower first request can resolve *after* the second
and overwrite `editingWI` with the wrong identity.

Added a sequence-counter guard (`selectSeqRef`): each click bumps the
counter, and only the response whose captured `seq` still matches the
latest counter is allowed to update state. Stale responses are dropped.

## Environment reorder drag-and-drop was broken

Reported by the user — dragging environments in the Reorder sheet did
nothing. Two root causes:

1. **`onDragStart` never initialized `dataTransfer`.** HTML5 DnD requires
   `dataTransfer.setData()` to start a drag in Firefox, and
   `effectAllowed` + `dropEffect` for the drop cursor in Chrome. The old
   handler only called `setDragIndex(index)` on a closure that didn't
   receive the DragEvent — so dataTransfer was never touched. In some
   browsers this silently aborted the drag.

2. **Mid-drag list mutation.** The old `onDragOver` spliced `list` and
   called `setList(newList)` on *every* dragover event. Each `setList`
   re-renders the whole sheet Popup, and Chrome's drag session can abort
   when the source element re-mounts or its identity changes during the
   drag.

Rewrote with standard HTML5 DnD pattern:
- `onDragStart` sets `effectAllowed = "move"` and
  `dataTransfer.setData("text/plain", String(index))`.
- `onDragOver` calls `preventDefault()` (to mark the element as a valid
  drop target) and updates a separate `dragOverIndex` state purely for
  visual feedback (border highlight). The list itself is not mutated.
- `onDrop` reads the source index from `dataTransfer`, splices once,
  calls `setList` once.
- `onDragEnd` clears both `dragIndex` and `dragOverIndex`.

Also added a visible drop-target highlight (`border-accent bg-accent/5`)
on the hovered row so the user sees where the drop will land.

Type-check, lint, all checks pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
My previous HTML5 DnD rewrite still didn't work inside the Sheet. Root
cause: Base UI's Dialog.Popup uses a focus trap + CSS transform
animation (`data-starting-style: translate-x-full`), and native HTML5
drag-and-drop fights with both — the transform on the popup element
messes up drag-image coordinates in Chrome, and the focus trap can
intercept certain pointer events. This is a known limitation of raw
HTML5 DnD inside modal dialog primitives.

The project already depends on `@dnd-kit/core`, `@dnd-kit/sortable`,
and `@dnd-kit/utilities` (used by `CustomApproval/RulesSection.tsx`'s
rule reorder). dnd-kit uses pointer-event-based drag tracking (not
native HTML5 DnD) and works reliably inside Base UI Dialog popups.

Rewrote `ReorderSheet` + `SortableEnvironmentRow` to match the existing
pattern in `RulesSection.tsx`:

- `DndContext` with `PointerSensor` (4px activation distance to avoid
  click-vs-drag ambiguity on the grip handle) and `KeyboardSensor`
  (accessible reorder via Tab + Space + Arrow keys)
- `SortableContext` with `verticalListSortingStrategy` wrapping the
  rows
- `useSortable({ id: env.id })` per row, with the grip handle getting
  the `attributes + listeners` (not the whole row — that would conflict
  with click-to-something-else if we add other row interactions later)
- `arrayMove` on drop, matching the local-state update pattern used by
  RulesSection
- `touch-none` class on the grip handle so touch devices don't scroll
  while dragging

Bonus: `KeyboardSensor` + `sortableKeyboardCoordinates` gives us
accessible reorder for free — tab to a grip handle, press Space to
pick up, arrow keys to move, Space to drop.

Type-check + check + 1031 tests all pass.

Reported by the user after the HTML5 DnD fix still didn't work in
practice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous attempt used @dnd-kit correctly but the visual reorder kept
snapping back because the outer ReorderSheet's reset `useEffect` was
firing on every parent render and resetting `list` from the incoming
`environments` prop.

Root cause: `EnvironmentsPage` passes `environments={environmentList}`
where `environmentList = useVueState(() => [...store.environmentList])`.
`useVueState`'s getter returns a new array reference on every render
(via the spread), which means the `environments` prop is a new
reference on every parent render. The reset effect
`useEffect(() => { ... setList([...environments]) }, [open, environments])`
therefore fired after every drop (triggered by any unrelated parent
re-render), clobbering the user's reorder back to the original order.

Fix: split into outer wrapper + inner form (matching the pattern used
by the other edit sheets in this PR). The outer wrapper:

1. Tracks an `openCount` that bumps on each closed→open transition.
2. Snapshots `environments` at the moment of the open transition into
   a ref, so the starting order is captured once.
3. Keys the inner component by `openCount`, forcing a clean remount
   with a fresh `list` state whenever a new open happens. No reset
   `useEffect` needed — `useState(() => [...environments])` in the
   inner component does the initialization correctly.

The inner component:

- Captures the initial order into `initialOrderRef` at mount so
  `orderChanged` compares against the stable baseline, not the live
  `environments` prop (which keeps churning references).
- Uses functional `setList((prev) => arrayMove(prev, ...))` so the
  drag-end handler always operates on the latest state, even if
  multiple drops fire in quick succession.

This matches the stable-entity ref pattern used by
`CreateWorkloadIdentitySheet` / `CreateUserSheet` / etc. — same class
of bug (useVueState spreads → new reference every render → naive
reset effects clobber local state), same class of fix.

Reported by the user: "after I dragged the environment to the spot I
want to (swapping environments), there is no change after I released
my mouse."

Type-check, lint, all checks green.

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

Copy link
Copy Markdown

@d-bytebase
d-bytebase enabled auto-merge (squash) April 10, 2026 23:44
@d-bytebase
d-bytebase merged commit ba5b541 into main Apr 10, 2026
15 of 16 checks passed
@d-bytebase
d-bytebase deleted the feat/sheet-primitive-and-drawer-cleanup branch April 10, 2026 23:44

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08fe45cdc9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +153 to +156
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onUserSelected(user);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore key events from nested controls in row handler

The row-level keyboard handler reacts to Enter/Space for any bubbled key event, but nested action buttons only stop click propagation. When a keyboard user focuses the delete/restore button and presses Enter or Space, this handler also fires onUserSelected(user) (and calls preventDefault), so the detail sheet opens unexpectedly and can interfere with the intended button action. Restrict the row handler to e.target === e.currentTarget (or stop keydown propagation from child controls).

Useful? React with 👍 / 👎.

Comment on lines +339 to +343

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cancel pending identity fetch when switching to create mode

The async selection guard only advances selectSeqRef for row selections, so an in-flight getOrFetchWorkloadIdentity can still commit after the user switches to another flow (for example, clicking Create). In that case the stale response still matches the sequence and overwrites editingWI, replacing the create form with an unrelated identity. Invalidate the selection token when opening create/closing, or gate the post-await update on the current sheet mode.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants