Skip to content

chore(studio): react-doctor follow-up — state reducers + component decomposition#147

Merged
iipanda merged 3 commits into
mainfrom
chore/react-doctor-followup-state-and-decomposition
May 18, 2026
Merged

chore(studio): react-doctor follow-up — state reducers + component decomposition#147
iipanda merged 3 commits into
mainfrom
chore/react-doctor-followup-state-and-decomposition

Conversation

@iipanda
Copy link
Copy Markdown
Collaborator

@iipanda iipanda commented May 18, 2026

Summary

Follow-up to #146. Addresses the deferred work from the previous react-doctor cleanup pass: prefer-useReducer, no-cascading-set-state (where naturally combinable with reducers), and the smaller no-giant-component extractions. All edits land in runtime-ui/** paths declared as unpublishedSources in .changeset-gate.json, so no changeset is required.

Changes

useReducer migrations

  • api-key-create-dialog.tsx: 6 form useState calls + the cascading reset effect collapsed into one discriminated-union reducer (label-change / scope-toggle / expires-at-change / submit-* / copy-set / reset). The on-open effect now both recomputes todayMinDate and dispatches reset on close.
  • users-page.tsx invite form: the 3-field invite state plus its error string moved into an inviteFormReducer inside the new InviteUserDialog. The parent no longer owns invite form state.

Component decomposition

  • users-page.tsx (was 700 lines / 576-line body) → page orchestrator (~165 lines) + InviteUserDialog, EditRoleDialog, PendingInvitesList, UsersTable. Page is no longer flagged as a giant component.
  • trash-page.tsx (was ~390-line body) → TrashFilterBar, TrashTable, TrashEmptyMatch, TrashPagination extracted. Page is no longer flagged.
  • content/[type]/page.tsx: ContentTypeDocumentsTable, ContentTypePaginationBar extracted. The page is still over the threshold because of query/mutation wiring; deeper decomposition deferred.

Doctor-score delta

Package Before After
@mdcms/studio 84/100 84/100 (qualitative; remaining warnings overlap categories)
no-giant-component flagged sites 8 6

The numeric score is unchanged because the remaining giant components (content-document-page ×2, environments-page, assistant-context provider, layout, inline-ai-bubble) and the documented false-positives still account for the warning population. A separate PR can tackle them.

Deferred (out of scope for this PR)

  • content-document-page.tsx ContentDocumentPageView (576-line body) and ContentDocumentPage (1142-line body) — the document editor is the most state-dense surface in the studio; a careful split needs its own design pass.
  • environments-page.tsx (563-line body, 17+ useStates) — the same; a focused state-machine refactor would be cleaner than mechanical extractions.
  • assistant-context.tsx AssistantProvider (615-line body) — provider with deeply interconnected chat dispatch state; pulling a hook out is fine but doesn't move the lint number cleanly.
  • inline-ai-bubble.tsx (manageable but the two no-effect-chain sites are legitimate debounce + async-state lifecycles — see commit on the original PR).
  • layout.tsx AdminLayoutInner (235-line body) — auth gating + query setup is interleaved; a clean split requires moving the early-returns through a new gate component.

These are intentionally out of scope and can ship as separate focused PRs.

Test plan

  • bun run check (build + typecheck across 6 projects) — green.
  • bun test --cwd packages/studio — 603/603 pass.
  • bun run format:check — no studio-touching files dirty.
  • Manual smoke (browser): invite flow, edit-role flow, trash list paging, content/[type] list paging.
  • bun run ci:required in CI.

Summary by CodeRabbit

  • Refactor
    • Reorganized Content, Trash, and Users admin pages into clearer componentized UI for tables, pagination, filters, and dialogs.
    • Trash page adds improved filter bar, empty-state messaging, per-row restore actions, and paged navigation.
    • Users page introduces invite and edit-role dialogs, pending-invites list, role display and scoped-role handling, and row action menus (edit role, revoke sessions, remove user with owner protection/tooltips).
    • API key creation dialog now has a streamlined multi-step flow with copy-to-clipboard and expiry controls.

Review Change Stack

iipanda added 2 commits May 18, 2026 13:46
- api-key-create-dialog: 6 form useStates collapsed into a single reducer
  (step, label, selectedScopes, expiresAt, createdResult, copied, submitError).
  Reset effect now dispatches `reset` instead of cascading individual setters;
  todayMinDate recompute moved into the same on-open effect.
- users-page: extracted InviteUserDialog (with its own reducer), EditRoleDialog,
  PendingInvitesList, and UsersTable. UsersPage orchestrator dropped from 576 to
  ~160 lines.

Remaining (deferred to follow-up worktree):
- environments-page giant-component split
- content-document-page section extractions
- content/[type]/page, trash-page, layout, inline-ai-bubble, assistant-context
- trash-page: TrashFilterBar, TrashEmptyMatch, TrashTable, TrashPagination
  extracted. TrashPage body drops from ~390 lines to ~190.
- content/[type]/page: ContentTypeDocumentsTable + ContentTypePaginationBar
  extracted. ContentTypePage shrinks but is still above threshold due to
  query/mutation wiring; a deeper split is deferred.
- users-page formatting pass after prior reducer + decomposition commit.

Drops `no-giant-component` count by 1 (trash-page no longer flagged).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d38e6bc4-cd95-4279-ac3d-82a0c03eb6aa

📥 Commits

Reviewing files that changed from the base of the PR and between b142063 and 4d94ff5.

📒 Files selected for processing (4)
  • packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/trash-page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/users-page.tsx
  • packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/studio/src/lib/runtime-ui/app/admin/trash-page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx
  • packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/users-page.tsx

📝 Walkthrough

Walkthrough

This PR extracts table/pagination/filter/dialog UI into internal components across ContentTypePage, TrashPage, and UsersPage, and consolidates ApiKeyCreateDialog form state with a reducer. No exported/public signatures changed.

Changes

Admin UI Component Extraction and State Consolidation

Layer / File(s) Summary
ContentTypePage table and pagination extraction
packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx
Introduces ContentTypeDocumentsTable and ContentTypePaginationBar and updates ContentTypePage to use them for document listing and paging.
TrashPage UI component extraction
packages/studio/src/lib/runtime-ui/app/admin/trash-page.tsx
Adds TrashFilterBar, TrashEmptyMatch, TrashTable, and TrashPagination, and wires them into TrashPage ready/status branches.
UsersPage dialog and table extraction with state consolidation
packages/studio/src/lib/runtime-ui/app/admin/users-page.tsx
Extracts InviteUserDialog and EditRoleDialog, adds PendingInvitesList and UsersTable, refactors getHighestRole typing, simplifies UsersPage state, updates error handling, and composes the new components in the page.
ApiKeyCreateDialog form state consolidation via useReducer
packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx
Introduces FormState/FormAction and formReducer to centralize dialog state, replacing multiple useState hooks and updating handlers and submit/copy flows to dispatch reducer actions.

Possibly related PRs:

  • mdcms-ai/mdcms#141: Related ContentTypePage table refactor touching row-action vs row-navigation behavior.
  • mdcms-ai/mdcms#146: Similar extraction of per-row action dropdowns and table/action componentization.

🎯 3 (Moderate) | ⏱️ ~25 minutes

"I'm a rabbit in the dev shrub,
extracting widgets with a little thub,—
reducers tidy, tables shine,
pagination hops in tidy line.
Hooray for cleaner UI time!" 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: state reducer migrations and component decomposition in the studio runtime-ui package.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/react-doctor-followup-state-and-decomposition

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx (1)

118-144: ⚡ Quick win

Return a fresh state object in the reset case.

The "reset" case returns initialFormState directly, which means all reset operations share the same Set instance for selectedScopes. While the current reducer implementation never mutates Sets directly (always creating new instances), this pattern is fragile—if future code accidentally mutates state.selectedScopes directly, it would corrupt the shared initial state.

♻️ Proposed fix to return a fresh state on reset

Option 1 (inline):

 function formReducer(state: FormState, action: FormAction): FormState {
   switch (action.type) {
     case "reset":
-      return initialFormState;
+      return { ...initialFormState, selectedScopes: new Set() };

Option 2 (factory function):

-const initialFormState: FormState = {
+const getInitialFormState = (): FormState => ({
   step: "form",
   label: "",
   selectedScopes: new Set(),
   expiresAt: "",
   createdResult: null,
   copied: false,
   submitError: null,
-};
+});

 function formReducer(state: FormState, action: FormAction): FormState {
   switch (action.type) {
     case "reset":
-      return initialFormState;
+      return getInitialFormState();

Then update the useReducer call:

-const [form, dispatch] = useReducer(formReducer, initialFormState);
+const [form, dispatch] = useReducer(formReducer, getInitialFormState());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx`
around lines 118 - 144, The reset branch in formReducer currently returns the
shared initialFormState object, risking shared mutable state for selectedScopes;
change the "reset" case to return a fresh state object (not initialFormState) by
copying primitive fields and creating a new Set for selectedScopes (e.g., new
Set(initialFormState.selectedScopes)) so formReducer always returns independent
state on "reset"; update any useReducer initialization only if you adopt an
initial state factory but ensure selectedScopes is newly constructed when
resetting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/studio/src/lib/runtime-ui/app/admin/content/`[type]/page.tsx:
- Around line 431-433: The pager currently always renders pages 1..5 and moves
offset without bounds; update the page-range calculation (the Array.from mapping
that builds page links) to compute a clamped window based on currentPage and
totalPages (e.g. compute start = Math.max(1, Math.min(currentPage - 2,
totalPages - 4)) and end = Math.min(totalPages, start + 4)) so the rendered
pages shift when currentPage > 5, and replace the direct onPageChange(offset +
PAGE_SIZE) calls with a bounded calculation that clamps the new offset to [0,
(totalPages - 1) * PAGE_SIZE] (using Math.max/Math.min) before calling
onPageChange; update both occurrences (the Array.from pagination block and the
handler at the other occurrence) and keep using the existing symbols
currentPage, totalPages, PAGE_SIZE, offset, and onPageChange.
- Around line 335-339: The TableRow currently only supports mouse clicks; make
it keyboard-accessible by adding tabbable and ARIA affordances: give the
TableRow (the element rendering the row with key={doc.documentId}) a
tabIndex={0}, role="button" (or role="link" if more appropriate) and an
onKeyDown handler that calls onRowClick(doc.documentId) when Enter or Space is
pressed; also ensure any existing onClick still calls onRowClick and add an
appropriate aria-label or aria-labelledby so screen readers announce the row
action.

In `@packages/studio/src/lib/runtime-ui/app/admin/trash-page.tsx`:
- Around line 309-311: Pagination currently renders a fixed window of pages 1–5
using Array.from({ length: Math.min(5, totalPages) }) which ignores currentPage;
change the page window calculation to center on currentPage (e.g., compute start
= Math.max(1, Math.min(currentPage - 2, totalPages - 4)) and end =
Math.min(totalPages, start + 4)) and render pages from start..end so the visible
page links move with currentPage; also update the page-change handler(s) that
compute next/previous pages (the onClick/onChange that sets currentPage) to
clamp the new page with Math.min/Math.max against 1 and totalPages so
advancing/offsets never exceed bounds.

In `@packages/studio/src/lib/runtime-ui/app/admin/users-page.tsx`:
- Around line 364-404: handleSave currently calls updateGrants without error
handling; wrap the async updateGrants call in a try/catch inside handleSave,
introduce a component state variable (e.g., error and setError) to store any
error message, on success proceed to call onSaved(target.userName) and
onOpenChange(false) but on failure setError with a user-friendly message (or
error.message) and do not close the dialog, and update the dialog JSX to render
{error && <p className="text-sm text-destructive">{error}</p>} similar to
InviteUserDialog so users see the failure.

---

Nitpick comments:
In `@packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx`:
- Around line 118-144: The reset branch in formReducer currently returns the
shared initialFormState object, risking shared mutable state for selectedScopes;
change the "reset" case to return a fresh state object (not initialFormState) by
copying primitive fields and creating a new Set for selectedScopes (e.g., new
Set(initialFormState.selectedScopes)) so formReducer always returns independent
state on "reset"; update any useReducer initialization only if you adopt an
initial state factory but ensure selectedScopes is newly constructed when
resetting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 73a1a4ca-52ff-48c8-a820-1cab43e9c272

📥 Commits

Reviewing files that changed from the base of the PR and between c45cb8c and b142063.

📒 Files selected for processing (4)
  • packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/trash-page.tsx
  • packages/studio/src/lib/runtime-ui/app/admin/users-page.tsx
  • packages/studio/src/lib/runtime-ui/components/api-key-create-dialog.tsx

Comment thread packages/studio/src/lib/runtime-ui/app/admin/content/[type]/page.tsx Outdated
Comment thread packages/studio/src/lib/runtime-ui/app/admin/trash-page.tsx Outdated
Comment thread packages/studio/src/lib/runtime-ui/app/admin/users-page.tsx
- ContentTypePaginationBar + TrashPagination: page window is now centered on
  currentPage (clamped to [1, totalPages - 4..0]) instead of always 1..5, and
  every navigation handler clamps the new offset to [0, (totalPages-1)*PAGE_SIZE]
  so prev/next can't escape the bounds.
- ContentTypeDocumentsTable rows are now keyboard-accessible: tabIndex=0,
  role="button", aria-label="Open document {title}", and an onKeyDown that
  invokes the same onRowClick on Enter or Space.
- EditRoleDialog handleSave wraps updateGrants in try/catch, stores an
  error string in component state, renders it below the form fields, and
  leaves the dialog open on failure so the user sees what went wrong.
- formReducer "reset" no longer returns the shared initialFormState (whose
  `selectedScopes` Set could be mutated through the module-level reference).
  Switched to a createInitialFormState() factory and call it from the reset
  branch to hand back a fresh Set each time.
@iipanda iipanda merged commit 4508aa0 into main May 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant