Repository navigation
feat(frontend): Sheet primitive, migrate legacy drawers, standardize resource-list UX - #19962
Conversation
…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>
There was a problem hiding this comment.
💡 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".
… 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>
There was a problem hiding this comment.
💡 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".
| ? (e) => { | ||
| if (e.key === "Enter" || e.key === " ") { | ||
| e.preventDefault(); | ||
| onUserSelected(user); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 👍 / 👎.





Summary
A consolidated consistency + bugfix pass over the React resource-list pages, with three main components:
Sheetprimitive infrontend/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.*Drawer→*Sheetand migrated from hand-rolledfixed inset-y-0 right-0markup (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.Plus two hard-to-find bug fixes uncovered during the migration:
WorkloadIdentitiesPage.handleUserSelectedcalled the syncgetWorkloadIdentity(email)which returns a stub (noworkloadIdentityConfig) when the cache is empty — and the list fetch never populated the cache. Fixed by switching to asyncgetOrFetchWorkloadIdentity.<Sheet open={open}>pattern meansuseStateinitializers only run once. Fixed by introducing an outer wrapper + inner form +keypattern 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(withwidthvariant:narrow384px /standard704px /wide832px),SheetHeader,SheetBody,SheetFooter,SheetTitle,SheetDescription,SheetClose,SheetOverlay. Built-in close button in top-right.z-50per the overlay-stacking rule infrontend/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.tsxRequestRoleDialog→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— inlineEditEnvironmentDrawer(narrow)LandingPage.tsx—ConfigDrawer(narrow)Edit-sheet infrastructure fixes
The always-mounted Sheet pattern broke
useStateinitialization on re-open. Fixed using the outer-wrapper + inner-form +keypattern across all edit sheets:{open && ...}unmounts inner form on close,keyforces remount when the edited entity changes, anduseStateinitializers 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.handleUserSelectednow usesgetOrFetchWorkloadIdentity(async) instead of the syncgetWorkloadIdentitywhich returned a stub withoutworkloadIdentityConfigwhen 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 bluetext-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.mdguideline updateNew section under
## shadcn Component Guidelines → Rules:<Sheet>vs<Dialog>vs<AlertDialog>keypattern for edit sheets (with code sample)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)
Test plan
pnpm --dir frontend type-checkpassespnpm --dir frontend check(ESLint + Biome + i18n + locale sort) passespnpm --dir frontend test— all 1031 unit tests passwidedrawer; step indicator in header, form in body, Back/Next/Create in footer*Sheetshould open, show its form, accept input, and submitFollow-ups (intentionally out of scope)
TableDetailDialog(frontend/src/react/pages/project/database-detail/overview/TableDetailDialog.tsx, 1341 lines) — complex table metadata editor with nestedClassificationPickerDialogandSemanticTypePickerDialog. Deserves its own PR to decide whether the nested pickers should remain centered dialogs on top of the Sheet, or become in-Sheet popovers.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.readOnlyprop + hiding the Save button entirely is a nice follow-up but not required for the row-click UX change to work.frontend/AGENTS.md; a future lint rule or codemod to flag directfixed inset-y-0 right-0hand-rolled drawers would be nice.🤖 Generated with Claude Code