Skip to content

Commit 3acbeff

Browse files
refactor(app-shell): move ObjectView's list toolbar affordances into a sibling hook (slice 7) (#26374)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VtKf3F9dzJPJM4RRtABaEc)_ Part of #25992 (slice 7 of the move-only split; slices 1 to 6 landed as #26152, #26233, #26310, #26336 and #26353, and the card stays open for the slices listed below). Clause-②: no Metadata-protocol: none ## What moved Base `3db7bbfc` (slices 5+6 merged). `ui/packages/app-shell/src/views/ObjectView.tsx` is 2926 lines there. Concern **D** of the seam table, the list toolbar's affordances, moves verbatim into the custom hook `useObjectViewToolbarAffordances` in a new sibling module `ui/packages/app-shell/src/views/objectViewToolbarAffordances.ts`. It moves in two blocks: - lines 503–534: the environment list's entitlements (`isEnvironmentList`, `useEnvironmentEntitlements`), the read gate on the object's list actions (`listActionsReadable`, `listActions`), and `localizedToolbarActions`, - lines 563–682: the create predicates (`objectCanCreate`, `createVisible`, `createDisabled`), `listAddRecordOpensCreate`, `pageOffersCreate` with the capability gate and predicate scope it reads, and the import predicates (`objectCanImport`, `importVisible`, `importDisabled`), with every docblock. The hook is called as `useObjectViewToolbarAffordances({ objectDef, dataSource, actionRuntime, refreshKey, perms, localizeActionTexts, newVerdict, importVerdict })`. It returns the twelve names the rest of the component reads, and the component destructures them under their original names. No identifier is renamed and no context is added. The parameters are typed from their sources, with no `any`: `ConsoleObjectViewProps['objects'][number]`, `DataSource`, `Pick<ConsoleActionRuntime, 'authFetch'>`, `ReturnType<typeof usePermissions>`, `ReturnType<typeof useActionTextLocalizer>`, and `ReturnType<typeof resolveAffordance>` for both verdicts. **What stays.** The verdict memo between the two blocks (lines 536–557: `newVerdict` and `importVerdict` from `resolveAffordance('listNew' | 'listImport', …)`) stays in the component and is passed in. `affordanceGrantMap-12082.test.tsx` keys the `listImport` row's reader by file: the write site `ImportWizard > legacyImport :: create` names `ObjectView.tsx` as a file that reads `listImport`, and every file it names must read one of its rows. Keeping the `resolveAffordance` call here keeps that census green with no test edit. The three-line comment on `refreshKey` after the memo stays too. **Hook order.** The hook is called right after the verdict memo, so the first block's hooks (`useEnvironmentEntitlements`, the `localizedToolbarActions` memo) now run after that memo instead of before it. They share no value with it, the order is the same on every render, and no effect sits between them, so the effects' order is unchanged. **Imports.** Each import moves with its only readers: `useRowPredicate`, `useCapabilityGate`, `usePredicateScope`, `RowCrudPredicates`, `listToolbarDrawsAction`, `mayReadObject` and `useEnvironmentEntitlements`. Nothing new is exported from `ObjectView.tsx`, and the package entry is untouched. `ObjectView.tsx` goes from 2926 to 2797 lines. Move-only proof: the multiset of non-blank lines of the base `ObjectView.tsx` is contained in new `ObjectView.tsx` plus `objectViewToolbarAffordances.ts`. The only base lines missing are the two import lines that lost a moved name (`managedByEmptyState.js`, `@object-ui/react`), each replaced by its trimmed form. The added lines are the trimmed imports and the new import, the module header and imports, the hook signature and parameter types, the `return` object, and in `ObjectView.tsx` the call with its destructuring and two-line comment. ## Test edits None. `affordanceGrantMap-12082` is covered by keeping the verdict memo in place (above). No other source reader of `ObjectView.tsx` names a moved line. ## Seam table rows this PR changes (measured on `3db7bbfc`) The full table (regions A to Q) is in #26336. | # | Concern | Lines on `3db7bbfc` | Reads | Read by | Slice | |---|---|---|---|---|---| | D | Environment entitlements, list actions, create and import toolbar predicates | 503–534, 563–682 | A (`localizeActionTexts`), C (`objectDef`, `perms`, `refreshKey`, `actionRuntime`), D-verdicts, prop `dataSource` | G, O, P, Q | **7, this PR** | | D-verdicts | `resolveAffordance` verdicts for `listNew` and `listImport` | 536–557 | C (`objectDef`, `perms`) | D | stays (grant census reads it in this file) | ## Remaining slices (the last closes the card) - **Slice 8 — G + H + L-filter:** header toolbar actions, the list URL state and the URL/user filter block into sibling hooks; re-points `ObjectView.urlFilterSuffix-9196.test.ts` to the new file as a path-only edit if L-filter moves. - **Slice 9 — N + P:** the view tab bar and the plugin schema plus header chrome. - **Slice 10 — Q:** the JSX return's `PageHeader`, import wizard and `NavigationOverlay` blocks into sub-components with explicit props. - **Slice 11 — B + I + O (closes the card):** the view-config and view-tab CRUD handlers and `renderListView`, with the `writeRefusalCensus-11583` rows and the `relayRungCensus-7559` read re-pointed (path and file-key changes only). ## Verification See the os-dev-report comment on the card for the full readings: app-shell `type-check` after rebuilding its dependency closure, every `ObjectView.*` test in `views/`, the `AppContent*` tests, every other test that reads `ObjectView.tsx` as source, the app-shell tests that touch environment entitlements, the managed-by empty state, the object read gate or the CRUD affordances, eslint on the 2 touched files, the derived dispatch gates, and a Console build. ## Acceptance notes - eslint: 0 errors, and no new warning. The two files report 131 warnings, the same count as the base `ObjectView.tsx` alone; the two in the new module are casts moved verbatim (`(import.meta as any).env`, the `(a: any)` map callback). Changeset: `.changeset/25992-objectview-toolbar-affordances-split.md` (patch, `@object-ui/app-shell`). --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e7127b commit 3acbeff

3 files changed

Lines changed: 249 additions & 159 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
refactor(app-shell): the Console `ObjectView`'s list toolbar affordances move into a sibling hook
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The environment list's entitlements, the object's list actions and their localized toolbar copies, and the create and import buttons' toolbar-scope predicates move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx` into `useObjectViewToolbarAffordances` in `objectViewToolbarAffordances.ts` beside it. The object-level verdicts (`resolveAffordance`) stay in the component and are passed in with every other input. No logic, name or rendered output changes, and the package entry is unchanged.
11+
12+
What to do: nothing.

‎ui/packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 29 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,23 @@ import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext
7373
import { ManagedByBadge } from '../components/ManagedByBadge.js';
7474
import { isSetupAppSegment } from '../console/setupApp.js';
7575
import { RecordDetailView } from './RecordDetailView.js';
76-
import type { RowCrudPredicates } from '../utils/crudAffordances.js';
7776
import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js';
7877
import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js';
7978
import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js';
8079
import { importTargetFields } from './importTargetFields.js';
81-
import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../utils/managedByEmptyState.js';
82-
import { mayReadObject } from '../utils/navObjectReadGate.js';
80+
import { resolveManagedByEmptyState } from '../utils/managedByEmptyState.js';
8381
import { resolveViewId } from '../utils/resolveViewId.js';
8482
import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js';
8583
import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js';
8684
import { useObjectActions } from '../hooks/useObjectActions.js';
8785
import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n';
8886
import { usePermissions } from '@object-ui/permissions';
8987
import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth';
90-
import { ActionProvider, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope } from '@object-ui/react';
88+
import { ActionProvider, SchemaRenderer, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react';
9189
import { toast } from 'sonner';
9290
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js';
9391
import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js';
9492
import { actionRendersAt } from '@object-ui/types';
95-
import { useEnvironmentEntitlements } from '../environment/useEnvironmentEntitlements.js';
9693
import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js';
9794
import {
9895
VIEW_TYPE_ICONS,
@@ -125,6 +122,7 @@ import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession }
125122
import { useObjectViewLiveData } from './objectViewLiveData.js';
126123
import { useObjectViewIdentity } from './objectViewIdentity.js';
127124
import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js';
125+
import { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js';
128126

129127
// objectstack#25992 slice 1: these module-level helpers moved to the sibling
130128
// `objectView*.ts` modules above. Every name this file exported stays
@@ -500,39 +498,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
500498
});
501499
const { confirmHandler, toastHandler } = actionRuntime;
502500

503-
// Environment list (sys_environment) is entitlement- + state-aware: born
504-
// with one production env per org, its single `create_environment` action
505-
// means different things — "Set up your production environment", "Add
506-
// development environment", or a locked plan-tier mark linking to the
507-
// upgrade page (objectui#12190) — depending on org state.
508-
// Resolved org-side here; the hook is a no-op for every other object.
509-
const isEnvironmentList = objectDef.name === 'sys_environment';
510-
const environmentEntitlements = useEnvironmentEntitlements({
511-
enabled: isEnvironmentList,
512-
dataSource,
513-
authFetch: actionRuntime.authFetch,
514-
apiBase: (import.meta as any).env?.VITE_SERVER_URL || '',
515-
refreshKey,
516-
});
517-
// objectui#12109 — this object's list actions are drawn only for a caller
518-
// who may read the object. Every one of them is bound to it (an action's
519-
// `objectName` is what merges it into `objectDef.actions`), and a caller
520-
// without read gets the list's "You don't have access" under them, so a
521-
// drawn action could only end there. The verdict is the one the sidebar
522-
// prunes this object's navigation entry on (`mayReadObject`), so the entry
523-
// and its list actions go together. A boolean, read each render from the
524-
// provider's verdict, so the memo below keys on a primitive (AGENTS.md #10).
525-
const listActionsReadable = mayReadObject(perms.can, objectDef.name);
526-
const listActions = listActionsReadable ? objectDef.actions : undefined;
527-
// Localized `list_toolbar` actions, shared by the generic action bar and the
528-
// environment-aware toolbar (the action:bar renderer filters by location).
529-
// The header reads them through `headerToolbarActions`, which drops the
530-
// selection bar's duplicates once the active view is resolved (objectui#12462).
531-
const localizedToolbarActions = useMemo(
532-
() => (listActionsReadable ? objectDef.actions || [] : []).map((a: any) => localizeActionTexts(objectDef.name, a)),
533-
[objectDef, localizeActionTexts, listActionsReadable],
534-
);
535-
536501
// Resolve which generic CRUD affordances belong in the toolbar for
537502
// this object's lifecycle bucket (`managedBy`). config tables show
538503
// New/Edit/Delete but no CSV Import; system / append-only / better-auth
@@ -556,131 +521,36 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
556521
};
557522
}, [objectDef, perms]);
558523

524+
// objectstack#25992 slice 7: environment entitlements, the list actions and the
525+
// create and import toolbar verdicts moved to `./objectViewToolbarAffordances.ts`.
526+
const {
527+
isEnvironmentList,
528+
environmentEntitlements,
529+
listActions,
530+
localizedToolbarActions,
531+
objectCanCreate,
532+
createVisible,
533+
createDisabled,
534+
listAddRecordOpensCreate,
535+
pageOffersCreate,
536+
objectCanImport,
537+
importVisible,
538+
importDisabled,
539+
} = useObjectViewToolbarAffordances({
540+
objectDef,
541+
dataSource,
542+
actionRuntime,
543+
refreshKey,
544+
perms,
545+
localizeActionTexts,
546+
newVerdict,
547+
importVerdict,
548+
});
549+
559550
// Externally-triggered refreshes (e.g. global ModalForm submit, undo, redo)
560551
// reach every `refreshKey` reader through the sum declared with the counter
561552
// above (objectui#10572), not through a mirroring effect.
562553

563-
/**
564-
* [#5153] The object-list toolbar's CREATE predicates — the `create` half
565-
* of the toolbar-scope pair on THIS surface. #4646 (PR #5145) gave
566-
* `createPredicates` a consumer on the **related list**
567-
* (`RelatedRecordActionsBridge`); the standalone object-list page kept
568-
* gating its "New" on the object-level verdict alone, so one
569-
* `userActions.create` object form got two different verdicts depending on
570-
* which surface rendered the button — honoured on a record page's related
571-
* list, ignored here. `visibleWhen: false` (the objectui#3492 shape) did
572-
* not hide this "New".
573-
*
574-
* BINDING, LAYERING and the fail-CLOSED / fail-SOFT split are the import
575-
* half's, immediately below, verbatim — the spec types the two toolbar keys
576-
* identically and binds them in one breath. See that docblock for the
577-
* reasoning; the only thing that differs here is which affordance is gated.
578-
*
579-
* TWO RENDER POINTS, ONE VERDICT. The affordance surfaces twice on this
580-
* page — the PageHeader button (desktop) and the phone-only floating "+"
581-
* that stands in for it once the header is hidden. They are one affordance
582-
* rendered twice, so both consume these SAME two values; computing the
583-
* predicate once here is what keeps them from disagreeing with each other.
584-
*/
585-
const objectCanCreate = newVerdict.allowed;
586-
const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates;
587-
/** `visibleWhen` — fails CLOSED, declared-ness by `?? true`. As Import. */
588-
const createVisible = useRowPredicate(createPredicates?.visibleWhen ?? true, null, {
589-
fallback: false,
590-
warnOnError: true,
591-
label: 'builtin:create:visibleWhen',
592-
});
593-
/** `disabledWhen` — fails SOFT, `!= null` declared-ness OUTSIDE the eval. */
594-
const createDisabledPred = useRowPredicate(createPredicates?.disabledWhen, null, {
595-
fallback: false,
596-
warnOnError: true,
597-
label: 'builtin:create:disabledWhen',
598-
});
599-
const createDisabled = createPredicates?.disabledWhen != null && createDisabledPred;
600-
601-
/**
602-
* objectui#11687 — does this page offer a way to add a row? The New button
603-
* above, or a `list_toolbar` action the schema-driven toolbar draws (Invite
604-
* User, Register OAuth Application, …), each by the verdict its own control
605-
* renders on. The managed-by empty state reads it: a `better-auth` list
606-
* must not say rows are "not added by hand here" beside such a button.
607-
* A boolean, computed each render from the payload (`objectDef.actions`),
608-
* so `renderListView` depends on a primitive, not a memoised identity
609-
* (AGENTS.md #10).
610-
*/
611-
/**
612-
* objectui#13089 — the view's `addRecord` Add button opens this page's own
613-
* create (`actions.create`, the header New's), so it is offered exactly
614-
* where that New is offered and live: the object-level verdict, the
615-
* toolbar-scope `visibleWhen`, and not greyed by `disabledWhen` (`ListView`
616-
* draws no greyed Add button). A boolean, so `renderListView` depends on
617-
* a primitive (AGENTS.md #10).
618-
*/
619-
const listAddRecordOpensCreate = objectCanCreate && createVisible && !createDisabled;
620-
const mayInvokeToolbarAction = useCapabilityGate();
621-
const toolbarPredicateScope = usePredicateScope();
622-
const pageOffersCreate =
623-
(objectCanCreate && createVisible) ||
624-
listToolbarDrawsAction(listActions, mayInvokeToolbarAction, toolbarPredicateScope);
625-
626-
/**
627-
* [#5142] The object-list toolbar's IMPORT predicates — the `import` half
628-
* of the toolbar-scope pair the spec resolver emits, mirroring what
629-
* `RelatedRecordActionsBridge` does for `create` (objectui#4646).
630-
*
631-
* `@objectstack/spec@17.0.0` types `userActions.create` and
632-
* `userActions.import` identically and `resolveCrudAffordances` emits a
633-
* predicate envelope for each; the docblock binds them in one breath
634-
* ("`importPredicates` — same binding as `createPredicates`"). #4646 gave
635-
* `create` a consumer and left `import` declared-and-inert: an author could
636-
* write `userActions.import.visibleWhen`, have the spec accept it and the
637-
* resolver parse it, and watch this toolbar offer the CSV wizard anyway.
638-
*
639-
* BINDING (the spec docblock's, not an invention here). Unlike the ROW
640-
* predicates, a toolbar predicate evaluates ONCE per toolbar against the
641-
* record of the scope the toolbar sits in — the host parent record on a
642-
* related list, and **no record at all on a standalone object list**, which
643-
* is what this surface is. So `null` is passed deliberately below: a
644-
* predicate reading `record.*` has nothing to bind, faults, and — per the
645-
* fail-CLOSED rule — hides the button, exactly as the spec spells out.
646-
* Predicates over the host scope (`os.user.*` / `features.*`) bind normally
647-
* and are the meaningful shape here.
648-
*
649-
* LAYERING: surfaced only when the object-level verdict already passed —
650-
* the same posture the related-list bridge takes, because a predicate may
651-
* not RE-OPEN what the bucket, the effective API operations (objectstack#3391) or the
652-
* principal's grant have closed. The identity-import bypass below is a
653-
* different affordance entirely (it does not read the `listImport` row) and
654-
* is deliberately left outside this layer.
655-
*/
656-
const objectCanImport = importVerdict.allowed;
657-
const importPredicates: RowCrudPredicates | undefined = importVerdict.predicates;
658-
/**
659-
* `visibleWhen` — fails CLOSED, and counts as DECLARED by `?? true` rather
660-
* than by truthiness, so `visibleWhen: false` hides Import instead of
661-
* reading as "ungated" (the objectui#3492 invariant). The `true` default is
662-
* a boolean, which the evaluator short-circuits without touching the engine
663-
* — an object with no import predicates pays no evaluation at all.
664-
*/
665-
const importVisible = useRowPredicate(importPredicates?.visibleWhen ?? true, null, {
666-
fallback: false,
667-
warnOnError: true,
668-
label: 'builtin:import:visibleWhen',
669-
});
670-
/**
671-
* `disabledWhen` — fails SOFT (an unevaluable predicate must not grey a
672-
* button forever), with the `!= null` declared-ness gate OUTSIDE the
673-
* evaluation so `disabledWhen: ''` reads as "no condition" rather than as
674-
* "disable". Verbatim the posture of the record header (PR #4515), the
675-
* row kebab, and the related-list toolbar.
676-
*/
677-
const importDisabledPred = useRowPredicate(importPredicates?.disabledWhen, null, {
678-
fallback: false,
679-
warnOnError: true,
680-
label: 'builtin:import:disabledWhen',
681-
});
682-
const importDisabled = importPredicates?.disabledWhen != null && importDisabledPred;
683-
684554
// Import wizard open/close state — toolbar entry triggers it.
685555
const [showImport, setShowImport] = useState(false);
686556

0 commit comments

Comments
 (0)