From 3d8aec56daed3c3a7c99550833ecca89f6870b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:40:32 +0000 Subject: [PATCH 01/11] refactor(app-shell): move ObjectView's view-options family into objectViewOptions.ts (slice 2) The timeline/calendar/gantt/gallery/kanban ViewOptions, storedLegacyOptions, isSameOptionsValue and dispatchViewPatches move verbatim out of views/ObjectView.tsx into views/objectViewOptions.ts. ObjectView.tsx imports them back and re-exports every name it exported before. Test edits forced by the move: the four source scans that read the ObjectView face (calendarBinding-7029, galleryBinding-7547, ganttBinding-7070, titleFieldConvergence) now read ObjectView.tsx plus objectViewOptions.ts, and the writeRefusalCensus-11583 LEDGER row for dispatchViewPatches is keyed by its new file. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .changeset/25992-objectview-options-split.md | 12 + .../ObjectView.calendarBinding-7029.test.tsx | 9 +- .../ObjectView.galleryBinding-7547.test.tsx | 9 +- .../ObjectView.ganttBinding-7070.test.tsx | 9 +- .../ObjectView.titleFieldConvergence.test.tsx | 9 +- .../app-shell/src/views/ObjectView.tsx | 479 +----------------- .../app-shell/src/views/objectViewOptions.ts | 466 +++++++++++++++++ .../views/writeRefusalCensus-11583.test.ts | 2 +- 8 files changed, 521 insertions(+), 474 deletions(-) create mode 100644 .changeset/25992-objectview-options-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewOptions.ts diff --git a/.changeset/25992-objectview-options-split.md b/.changeset/25992-objectview-options-split.md new file mode 100644 index 00000000000..1a9c9fdbc8b --- /dev/null +++ b/.changeset/25992-objectview-options-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s view-options family moves into a sibling module + +Clause-②: no +Metadata-protocol: none + +The per-kind `options` blocks `views/ObjectView.tsx` hands `ListView` (`timelineViewOptions`, `calendarViewOptions`, `ganttViewOptions`, `galleryViewOptions`, `kanbanViewOptions`), `storedLegacyOptions`, the by-value options comparison and `dispatchViewPatches` move verbatim into `objectViewOptions.ts` beside it. No logic, name or rendered output changes. `ObjectView.tsx` re-exports every name it exported before, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx b/ui/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx index a77004de387..1ac7fc9102d 100644 --- a/ui/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx @@ -106,10 +106,11 @@ describe('calendarViewOptions — the object page forwards, it does not invent ( }); describe('no invented calendar field name survives in the source (objectui#7029)', () => { - const SOURCE = readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), 'ObjectView.tsx'), - 'utf8', - ); + // This face spans `ObjectView.tsx` and the view-options family it imports + // from `objectViewOptions.ts` (objectstack#25992 slice 2), so the scan reads both. + const SOURCE = ['ObjectView.tsx', 'objectViewOptions.ts'] + .map((file) => readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file), 'utf8')) + .join('\n'); /** * Executable lines only. The prose above this file's own seams names diff --git a/ui/packages/app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx b/ui/packages/app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx index c7ac7e5f4c4..58cc164c64f 100644 --- a/ui/packages/app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx @@ -152,10 +152,11 @@ describe('galleryViewOptions — the object page forwards, it does not invent (o }); describe('no invented gallery cover name survives in the source (objectui#7547)', () => { - const SOURCE = readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), 'ObjectView.tsx'), - 'utf8', - ); + // This face spans `ObjectView.tsx` and the view-options family it imports + // from `objectViewOptions.ts` (objectstack#25992 slice 2), so the scan reads both. + const SOURCE = ['ObjectView.tsx', 'objectViewOptions.ts'] + .map((file) => readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file), 'utf8')) + .join('\n'); /** * Executable lines only. The prose above this file's own seams names diff --git a/ui/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx b/ui/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx index bea185224e8..76a88439567 100644 --- a/ui/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx @@ -116,10 +116,11 @@ describe('ganttViewOptions — the object page forwards, it does not invent (obj }); describe('no invented gantt date name survives in the source (objectui#7070)', () => { - const SOURCE = readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), 'ObjectView.tsx'), - 'utf8', - ); + // This face spans `ObjectView.tsx` and the view-options family it imports + // from `objectViewOptions.ts` (objectstack#25992 slice 2), so the scan reads both. + const SOURCE = ['ObjectView.tsx', 'objectViewOptions.ts'] + .map((file) => readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file), 'utf8')) + .join('\n'); /** * Executable lines only. The prose above this file's own seams names diff --git a/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx b/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx index 9ca7468fe25..e4c8bc1dd07 100644 --- a/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx @@ -312,10 +312,11 @@ describe('ObjectView view-config `titleField` — the middle rung is gone (objec }); describe('the seven seams share ONE expression shape (objectui#6557)', () => { - const SOURCE = readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), 'ObjectView.tsx'), - 'utf8', - ); + // This face spans `ObjectView.tsx` and the view-options family it imports + // from `objectViewOptions.ts` (objectstack#25992 slice 2), so the scan reads both. + const SOURCE = ['ObjectView.tsx', 'objectViewOptions.ts'] + .map((file) => readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file), 'utf8')) + .join('\n'); /** Every `titleField:` / `labelField:` assignment in the file. */ const seamLines = SOURCE.split('\n').filter((l) => /^\s*(titleField|labelField):/.test(l)); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 2582a378f46..c80dfb5da63 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -57,7 +57,6 @@ import { useServedViewItems } from '../hooks/useServedViewItems.js'; import { viewTabLabel } from './viewTabLabel.js'; import { getIcon } from '../utils/getIcon.js'; import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig, ViewNavigationConfig } from '@object-ui/types'; -import { detectStatusField } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; @@ -116,6 +115,16 @@ import { setDefaultViewPatches, buildViewConfigSaveBody, } from './objectViewTabs.js'; +import { + timelineViewOptions, + calendarViewOptions, + ganttViewOptions, + galleryViewOptions, + kanbanViewOptions, + storedLegacyOptions, + isSameOptionsValue, + dispatchViewPatches, +} from './objectViewOptions.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -133,462 +142,18 @@ export { buildViewConfigSaveBody, } from './objectViewTabs.js'; -/** - * The `options.timeline` config this page hands to `ListView`. - * - * Deliberately does NOT resolve the date axis. `ListView.resolveTimelineDateBinding` - * is the single read-site that decides which field a timeline buckets by, and it - * reads the calendar binding when the view carries no timeline one. This face used - * to fabricate `startDateField: 'due_date'` for every object view — a field name - * the view never declared and most objects do not have — which both looked like a - * real binding downstream and, because it is always present, shadowed the calendar - * fallback entirely. The result on a calendar-bound view was a Timeline the - * switcher offered and the renderer bucketed wholly into "No date" (objectui#3129). - * - * What stays here is the view's OWN declared config, floored at `'name'`. An - * object-level `objectDef.titleField` leg used to sit in the middle of that - * chain; it was removed in objectui#6557 because `@objectstack/spec`'s object - * schema is a `strictObject` that REJECTS the key with `unrecognized_keys`, so - * no legal object metadata could ever reach it (objectui#6531 established the - * measurement, and dropped the twin read inside `getRecordDisplayName`). - * - * ⚠️ WHAT THE SIBLING BRANCHES BELOW ACTUALLY DO (`5f4514f7b`). The sentence - * above used to end "— the same two-rung shape the calendar and gantt branches - * below already use", and for gantt that was FALSE the whole time it stood: the - * gantt branch floored `startDateField` / `endDateField` at `'start_date'` / - * `'end_date'`, which is precisely the one-rung fabrication this note declares - * retired. A note is the authority the next fixer consults, so it vouched for - * the lines that were broken. Each branch is therefore stated as MEASURED, not - * as a family: - * - * - `calendarViewOptions` below — forwards a declared block or emits no - * `calendar` key at all; it never floors a date axis (objectui#7029). - * - `ganttViewOptions` below — forwards the declared block, title floored at - * `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'` - * floors were deleted by `5f4514f7b`; that is what makes `ObjectGantt`'s - * own "Gantt configuration required" screen reachable from this route. - * - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx` - * and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at - * `'created_at'`, the very literal objectui#3129 retired HERE, and - * `ListView` carried it as a stated DECISION ("`created_at` stays the last - * resort for a view that declares no date axis anywhere") — two faces - * holding documented and OPPOSITE postures on one field name. That is what - * `5f4514f7b` routed to a single ruling instead of settling per-face, and - * the ruling (2026-09-01, 总监批 #28) answered it as house posture: - * 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both - * floors, so all three faces now forward a declared axis or none, and - * `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable - * from every one of them. This note no longer describes only THIS face. - * - * Exported for the regression suite. - */ -export function timelineViewOptions(viewDef: any): Record { - // The spec key only. objectui#6152 round 14 retired the pre-#2231 - // `timeline.dateField` rung: every list-view door refuses the alias by name, - // and `ListView` no longer reads it either. - const declaredStart = viewDef?.timeline?.startDateField; - return { - // Spread the full view-defined timeline config first so the spec fields - // (startDateField/endDateField/groupByField/colorField/scale) survive. - ...(viewDef?.timeline || {}), - // Only ever restate a binding the view actually declared. - ...(declaredStart ? { startDateField: declaredStart } : {}), - titleField: viewDef?.timeline?.titleField || 'name', - // objectui#6152 round 12: no `descriptionField`. The spec's timeline block - // has no such member, the bag refuses it, and nothing drew it: it landed - // only in the generated node's nested block, which `ObjectTimeline` never - // reads for a description (measured on objectui#6152, report 6070437445). - }; -} - -/** - * The `options.calendar` config this page hands to `ListView` — or NOTHING. - * - * objectui#7029 (ruled on objectstack#13748, director batch #19, option A). - * This face used to fabricate `startDateField: 'due_date'` and - * `titleField: 'name'` for EVERY object view, declared or not. Downstream that - * is indistinguishable from a real binding, and it is what made the calendar - * renderer's own refusal screen ("Calendar configuration required…", - * `ObjectCalendar.tsx`) unreachable from this route: the renderer decides by - * asking whether a start-date binding is PRESENT, and this face always said - * yes. Measured on hotcrm's `crm_leave_request` (real fields `start_date` / - * `end_date`, no `calendar:` block): all nine records piled onto today's cell - * with titles resolved through the display-name chain — a plausible, fully - * wrong screen with zero signal to the author. - * - * The same fabrication also fed two gates that read this bag: - * `ListView.availableViews` offered the Calendar toggle for every object view, - * and `resolveTimelineDateBinding` accepts a calendar binding as a legitimate - * timeline axis — so the invented name silently answered for the Timeline - * switcher too. - * - * What stays is the view's OWN declared block, forwarded verbatim (spread, not - * whitelisted, so every spec key survives), and `undefined` when the view - * declared none. Exactly the shape the sibling faces already converged on: - * `timelineViewOptions` above (objectui#3129 retired this very literal from the - * timeline axis), the kanban branch's `detectStatusField` (ADR-0085, "never - * invents a field the object doesn't have"), and `defaultCalendarFromObject` - * in `InterfaceListPage` (a binding or `undefined`, never a guess). - * - * ⚠️ A view that carried no `calendar:` block and happened to sit on an object - * with a real `due_date` field was rendering by luck; it now reaches the - * refusal screen instead. That is the ruled loud-over-silent direction. - * - * Exported for the regression suite. - */ -export function calendarViewOptions(viewDef: any): Record | undefined { - const declared = viewDef?.calendar; - if (!declared || typeof declared !== 'object') return undefined; - // Forward what the author wrote — nothing invented, nothing floored. - return { ...declared }; -} - -/** - * The `options.gantt` config this page hands to `ListView`. - * - * `5f4514f7b` — the same class objectui#7029 removed from the calendar branch, - * reported separately by PR #7062 rather than fixed alongside it. This face used - * to floor `startDateField` at `'start_date'` and `endDateField` at `'end_date'` - * for EVERY object view, declared or not — field names no view had written and - * most objects do not carry. Downstream that is indistinguishable from a real - * binding, and it is what made `ObjectGantt`'s own refusal screen ("Gantt - * configuration required. Please specify startDateField, endDateField, and - * titleField.") unreachable from this route: `getGanttConfig` takes its flat - * branch as soon as BOTH date props are present, and this face always supplied - * both. It also answered the ADR-0047 capability gate in `ListView.availableViews` - * (`schema.options?.gantt?.startDateField`), so the Gantt toggle was live on - * every object view in the product. - * - * ⚠️ MEASURED BEFORE THE DELETION, because #7029's mechanic is only correct - * where a refusal path exists: `ObjectGantt` REFUSES an absent binding — it does - * not render empty and does not throw. Pinned as the seam in - * `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`. - * - * What stays is the view's OWN declared block, spread whole so every spec key - * the renderer reads survives (parentField/typeField for the summary→step - * hierarchy, baseline*, groupByField, resourceView/assignee*, tooltipFields, - * quickFilters, …) — a bare whitelist here once dropped every field past - * colorField and flattened the chart. `titleField` keeps its `'name'` floor: - * that is a display-name default, not a date axis, and it is the same rung - * `timelineViewOptions` above carries. - * - * Exported for the regression suite. - */ -export function ganttViewOptions(viewDef: any): Record { - return { - ...(viewDef?.gantt || {}), - // Only ever restates what the view declared — no date field is floored. - titleField: viewDef?.gantt?.titleField || 'name', - }; -} - -/** - * The `options.gallery` config this page hands to `ListView`. - * - * objectui#7547 — the last surviving member of the objectui#7029 / `5f4514f7b` / - * #7500 class on this face. It floored `imageField` at `'image'` for EVERY - * object view, declared or not: a cover binding no view had written, on a field - * name most objects do not carry. - * - * ⚠️ WHAT THAT LITERAL ACTUALLY DID, which is not what it looks like. The - * screen damage was small — `ObjectGallery` resolves the cover per record and - * COLLAPSES the cover area when no record yields one, so an invented `'image'` - * degraded to a gallery of coverless cards. The damage was to the GATE. This - * bag feeds `ListView.availableViews`, whose gallery check reads - * `schema.options?.gallery?.imageField`; a value that is always present makes - * that check answer YES for every object view that whitelists `gallery`, so - * ADR-0047's whitelist ∩ resolvable — the mechanism that exists to stop a - * visualization being offered with nothing behind it — was answering about a - * name this file wrote rather than one the author did. Gallery was offered - * without a block, and stayed watchable only because the renderer happens to - * degrade politely. That is a coincidence, not a design, and it is the same - * second-order effect objectui#7029 measured for `calendar` and `5f4514f7b` - * for `gantt`. - * - * ⚠️ MEASURED BEFORE THE DELETION, the discipline `5f4514f7b` wrote down: - * #7029's mechanic — delete the literal, let the read site answer — is only - * correct where the read site's answer is honest. `ObjectGallery` floors its - * own `coverField` at `'image'` too, and that rung STAYS: it is the component's - * own decision about an unconfigured record, and it is honest because the cover - * area collapses when the guess yields nothing. What this face must not do is - * pre-empt that decision and light the capability gate on the way past. - * - * What stays here is the view's OWN declared block, spread whole so every spec - * key survives (`cardSize` / `visibleFields` / `coverFit` / …), the cover - * binding under `coverField` BUT ONLY WHEN ONE WAS DECLARED, and the - * `titleField` `'name'` floor — a display-name default, not a binding, exactly - * as `ganttViewOptions` above keeps it. - * - * objectui#6152 round 12 (seat answer Q1 → A): the legacy `imageField` is no - * longer written beside `coverField`. `@object-ui/types` judges this bag as the - * spec's list overlay judges it, and that refuses `options.gallery.imageField` - * by name. The gate the paragraph above describes learned - * `options.gallery.coverField` in the same round, so the toggle still lights - * from what this writes and still stays dark when the view declared no cover. - * - * objectui#6152 round 14: the legacy READ is retired too. The cover binding is - * read from the spec's `coverField` alone, so a stored `gallery.imageField` - * binds no cover here, offers no Gallery toggle, and leaves `ObjectGallery` on - * its own `'image'` floor. - * - * Exported for the regression suite. - */ -export function galleryViewOptions(viewDef: any): Record { - const gallery = viewDef?.gallery; - // The cover binding goes out under the spec's `coverField` ONLY, read from - // `coverField` ONLY, and is never invented. objectui#6152 round 12 dropped the - // legacy `imageField` this used to cross-fill beside it, and round 14 retired - // the alias rung that still READ it. - const declaredCover = gallery?.coverField; - return { - ...(gallery || {}), - ...(declaredCover ? { coverField: declaredCover } : {}), - // Spelled through `viewDef` rather than the `gallery` local above, and - // that is load-bearing: `ObjectView.titleFieldConvergence.test.tsx` - // (objectui#6557) pins all six `titleField` / `labelField` seams to ONE - // expression shape — a chain of VIEW-declared rungs floored at 'name', - // with `viewDef` as the mechanical proxy for "view-declared". Reading - // the local here still resolves the same value but blinds that scan to - // the thing it hunts, so the seam keeps the family spelling. - titleField: viewDef?.gallery?.titleField || 'name', - }; -} - -/** - * The view-level KANBAN block this face forwards — the fifth member of the - * `timelineViewOptions` / `calendarViewOptions` / `ganttViewOptions` / - * `galleryViewOptions` family above, and the last one still inlined as an - * anonymous IIFE inside the schema literal (objectui#8193). - * - * ⭐ THE LANE KEY IS `groupByField`, THE SPEC'S OWN SPELLING. This used to emit - * the legacy `groupField` alias instead — never the canonical key, even though - * it already READ the canonical key first. The sibling producer - * `defaultKanbanFromObject` (`InterfaceListPage.tsx`) had migrated already and - * left the reasoning next to itself — "that read-site now prefers the spec key, - * so one key is enough" — but this twin twelve hundred lines away was not - * carried along, so ONE producer surface spoke two vocabularies for one concept - * depending on which entry point you arrived through. - * - * ⚠️ objectui#8193 changed only what this face WRITES; the alias READS stayed - * until objectui#6152 round 14 retired them here and in `ListView` alike. The - * lane is read from `groupByField` alone now, so a stored `kanban.groupField` - * no longer names it: the lane falls to the object's declared lifecycle, as for - * a view that names none. - * - * ⚠️ WRITING THE CANONICAL KEY HERE REQUIRED THE GATE TO LEARN IT, and that is - * the half a reader will not guess from this file. `ListView.availableViews` - * consulted the `options.` bag for the LEGACY spelling only - * (`schema.options?.kanban?.groupField`), so emitting `groupByField` alone made - * the Kanban capability stop resolving and the toggle vanish from every object - * view — measured, not reasoned: `{groupBy, groupField}` offered Kanban, and - * `{groupBy, groupByField}` did not. The gate now reads both spellings out of - * the bag, the same one-question-two-sites repair objectui#5042 made for `map` - * and objectui#7544 for `chart`. Pinned in `ObjectView.kanbanLane-8193` here - * and in `ListView.kanbanOptionsBagCanonical-8193` in `plugin-list`. - * - * ⭐ `groupBy` IS RETIRED HERE (objectui#8213) — the THIRD spelling of the one - * concept this expression already writes canonically. Measured against - * `@objectstack/spec` 17.3.0's strict `KanbanConfigSchema`, which declares - * `groupByField` / `summarizeField` / `columns` and refuses `groupBy` BY NAME, - * on a call where one control fires (`zzzBogusKey` is refused by name) and - * another accepts (`groupByField` alone draws no `unrecognized_keys`). Upstream - * knows the OTHER alias by name — probing `groupField` answers "Did you mean - * `groupField` → `groupByField`?" — and knows nothing at all about `groupBy`. - * - * ⚠️ THE PRODUCER CENSUS THE CARD MADE A PRECONDITION, run before the deletion. - * A structural scan of every tracked file for a bare `groupBy` inside a - * view-level `kanban`/`calendar`/`gallery`/`timeline`/`gantt` object literal - * found NO runtime producer but this one; the lit control on the same - * instrument (`groupByField` in the same position) returned 58 sites, so the - * empty reading is a reading. The only other authors are FIXTURES: the two arms - * of `ListView.kanbanOptionsBagCanonical-8193` that pin this very bag, and one - * stale metadata payload in `data-objectstack`'s `updateView.draft.test.ts`. - * The sibling producer `defaultKanbanFromObject` emits `{ groupByField }` alone - * and always has. - * - * ⚠️ WHY THE ONE READER WAS UNHARMED (as measured then). `ListView`'s two - * projection/expand collectors listed `v.groupByField, v.groupField, v.groupBy` - * as candidates for the same lane value, so `groupBy` did contribute a field - * NAME to the query projection — and this same expression writes that - * identical value under `groupByField`, which those collectors read first. The - * capability gate never read `groupBy` at all, and neither did the render - * branch. Since objectui#6152 round 14 the collectors, the gate and the render - * branch read `groupByField` alone. - * - * ⚠️ WHAT THIS CLOSED AND WHAT IT DID NOT — and what has since closed the rest. - * `ListView`'s kanban branch destructured - * `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the - * merged config and spread the REST *after* its own `groupBy: laneField`, so a - * surviving `groupBy` overrode the lane it had just resolved. This deletion - * removed the only producer in this repo that fed that override; the override - * itself stayed reachable from an author-written `kanban.groupBy` riding this - * repo's `.passthrough()` mirror, and was carried on its own card. - * ⭐ THAT CARD HAS LANDED (objectui#8365, maintainer ruling of 2026-09-12 — - * decision batch #117 item 5, option B): `groupBy` is now stripped in - * `ListView`'s destructure, so the canonical lane wins, AND the view-level - * `KanbanConfig` mirror (`@object-ui/types`, `zod/objectql.zod.ts`) declares it - * as an alias refusal naming `groupByField`, so the key is refused BY NAME at - * the read door instead of riding the passthrough. ⛔ Do not re-file it. - * - * ⭐ THE CARD FIELDS GO OUT AS `columns`, THE SPEC'S SPELLING (objectui#6152 - * round 12, seat answer Q1 → A). This wrote the legacy `cardFields` alias, which - * objectui#8213 left as a known residual. `@object-ui/types` now judges this bag - * as the spec's list overlay does and refuses `options.kanban.cardFields` by - * name, and `ListView`'s kanban branch reads `columns` for the cards when no - * `cardFields` is present: measured on objectui#6152 (report 6070437445), the - * generated node and the `$select` are identical under either spelling. - * `titleField` is a member of the spec's kanban block (measured on 17.7.0). Every key this - * expression writes is therefore a spec key, and - * `ObjectView.kanbanGroupByRetired-8213` pins that, so an undeclared key - * reddens instead of joining them quietly. - * - * ADR-0085: when the view doesn't pick a lane field, the object's declared - * lifecycle (`stageField`) decides — including the strict `stageField: false` - * suppression (no default lanes; the status-shaped field is declared - * non-linear) and the shared name/type heuristic, which never invents a field - * the object doesn't have (the old hard-coded 'status' did). - * - * objectui#11629: `summarizeField` (the spec's "Field to sum at top of column") - * is relayed only when the view declares it. This block is the only kanban - * config `ListView` receives on this page when the stored row carries no - * `options.kanban` bag. Without the relay, the key never reached `ListView`'s - * projection or the board's column headers, so a view that declared it - * rendered counts with no totals. Like the lane, an absent key stays absent: - * no default field is invented. - * - * Exported for the pin test. - */ -export function kanbanViewOptions(viewDef: any, objectDef: any): Record { - const lane = - viewDef?.kanban?.groupByField || - detectStatusField(objectDef as any) || - undefined; - const summarizeField = viewDef?.kanban?.summarizeField; - return { - ...(lane ? { groupByField: lane } : {}), - titleField: viewDef?.kanban?.titleField || 'name', - // The spec's own spelling of the card fields (objectui#6152 round 12): the - // bag refuses the legacy `cardFields` this used to write, and `ListView` - // reads `columns` for the cards, so the board is unchanged. - columns: viewDef?.kanban?.columns, - ...(summarizeField ? { summarizeField } : {}), - }; -} - -/** - * objectui#10380 — the stored row's legacy `options` bag, as this page hands it - * to `ListView`. A row with no bag (or a bag that is not an object) gives `{}`. - * - * `@objectstack/spec`'s flattened list overlay declares this bag - * (`ListViewOverlayOptionsSchema`). The view write door judges each - * `options.KIND` block key by key with that kind's own block schema, refuses - * an out-of-contract key by name, and stores what it accepts. The spec - * describes the bag as an underlay: the top-level `KIND` block "wins per key - * where both set one". The interface page has always forwarded the bag, so one - * stored row rendered two ways. Ruling A on objectui#10380 (comment - * 5824043998) keeps that forward ("what passes the door is legal"), so the - * object page now forwards the bag too. - * - * The relay below does three things with it, and none of them merges keys: - * - `options` carries the bag. For each kind the bag carries, the bag's block - * REPLACES this page's synthesized block (`kanbanViewOptions` and its - * siblings). This is the rule `InterfaceListPage` applies to its `mapCfg` - * (`view.options.map ?? derived`). A synthesized block stands in only for a - * kind the bag does not carry, so a row with no bag is relayed exactly as - * before. - * - For each kind the bag carries, the view's own top-level block also goes - * out at the top level, where `InterfaceListPage` puts it as well. - * - `ListView` then lays the top-level block over the bag per key. That merge - * already exists in each render branch (`{ ...options.KIND, ...KIND }`), - * and it is the spec's precedence. ⛔ No second merge is written here. - * - * Exported for the pin test. - */ -export function storedLegacyOptions(viewDef: unknown): Record { - const bag = viewDef && typeof viewDef === 'object' ? (viewDef as { options?: unknown }).options : undefined; - return bag && typeof bag === 'object' && !Array.isArray(bag) ? (bag as Record) : {}; -} - -/** - * objectui#10046 — is `a` the same `options` bag as `b`, compared by VALUE? - * (objectui#7237 holds the resolved `filter` through the same comparison.) - * - * `renderListView` rebuilds `fullSchema.options` as a fresh object literal on - * every call, and `plugin-view`'s `ObjectView` calls it on every one of its - * renders. `ListView` names `schema.options` BY IDENTITY in its fetch effect's - * dependency list (objectui#4567 ruled that dependency correct and put - * stabilisation at the PRODUCER), so each host re-render that changed nothing - * re-issued the identical `dataSource.find`: one extra at mount (the record - * count landing), two on opening a record and two on closing it. - * - * A copy of `plugin-view`'s `isStructurallyEqual` (`stableIdentity.ts`, - * objectui#6460), kept module-local because that one is not exported and - * exporting it would publish a new name. Same safety invariant: every - * uncertainty (an unmodelled type — function, `Map`, class instance —, a - * differing key count, a structure deeper than the bound) answers "not - * equal", which hands `ListView` a new identity and therefore a refetch. It can - * only ever remove a REDUNDANT query; it can never withhold a needed one. - * ⛔ Not `JSON.stringify`: that calls `{ a: undefined }` and `{}` equal (a - * missed refetch) and throws on a cycle. - */ -function isSameOptionsValue(a: unknown, b: unknown, depth = 0): boolean { - if (Object.is(a, b)) return true; - if (depth >= 12) return false; - if (a instanceof Date || b instanceof Date) { - return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); - } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; - return a.every((item, i) => isSameOptionsValue(item, b[i], depth + 1)); - } - const isPlain = (v: unknown): v is Record => { - if (v === null || typeof v !== 'object') return false; - const proto = Object.getPrototypeOf(v); - return proto === Object.prototype || proto === null; - }; - if (isPlain(a) && isPlain(b)) { - const keys = Object.keys(a); - if (keys.length !== Object.keys(b).length) return false; - return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isSameOptionsValue(a[k], b[k], depth + 1)); - } - return false; -} - -/** - * Issue one `updateView` per patch — **as a method call on the adapter** - * (objectui#4463). - * - * This exists to make one specific bug unwritable. `handleSetDefaultView` and - * `handleReorderViews` both used to detach the method before calling it (the - * reorder no longer writes a view at all since objectui#12418 — it is the - * user's preference): - * - * ```ts - * const updateView = (dataSource as any).updateView; // detached - * … - * updateView(objectName, target, patch); // `this` === undefined - * ``` - * - * The adapter's `updateView` opens with `await this.connect()`, so every write - * in the list rejected with `TypeError: Cannot read properties of undefined - * (reading 'connect')` **before any request was issued** — the user got a toast - * and the server never heard about it. "Set as Default" was dead on every saved - * view for exactly this reason, while rename and pin worked from the same menu: - * `handlePinView` always called it as a method, which is the in-file convention - * this restores. - * - * The call site routes through here, so there is a single place in the file - * that calls `updateView` over a patch list — and it has no local to detach into. - */ -export function dispatchViewPatches( - dataSource: any, - objectName: string, - patches: ReadonlyArray<{ viewId: string; patch: Record }>, -): Array> { - return patches.map( - ({ viewId, patch }) => dataSource.updateView(objectName, viewId, patch), - ); -} +// objectstack#25992 slice 2: the view-options family and `dispatchViewPatches` +// moved to `./objectViewOptions.ts`; every name exported here before stays +// exported from here. +export { + timelineViewOptions, + calendarViewOptions, + ganttViewOptions, + galleryViewOptions, + kanbanViewOptions, + storedLegacyOptions, + dispatchViewPatches, +} from './objectViewOptions.js'; /** * Props of the Console `ObjectView`, the route-level object surface diff --git a/ui/packages/app-shell/src/views/objectViewOptions.ts b/ui/packages/app-shell/src/views/objectViewOptions.ts new file mode 100644 index 00000000000..2e0a06e3431 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewOptions.ts @@ -0,0 +1,466 @@ +/** + * The view-options family of the Console `ObjectView` (`./ObjectView.tsx`): + * the per-kind `options` blocks it hands `ListView`, the stored legacy bag, + * the by-value options comparison and the view-patch dispatcher, moved out of + * that file verbatim (objectstack#25992, slice 2). `ObjectView.tsx` re-exports + * every name it exported before. + */ + +import { detectStatusField } from '@object-ui/types'; + +/** + * The `options.timeline` config this page hands to `ListView`. + * + * Deliberately does NOT resolve the date axis. `ListView.resolveTimelineDateBinding` + * is the single read-site that decides which field a timeline buckets by, and it + * reads the calendar binding when the view carries no timeline one. This face used + * to fabricate `startDateField: 'due_date'` for every object view — a field name + * the view never declared and most objects do not have — which both looked like a + * real binding downstream and, because it is always present, shadowed the calendar + * fallback entirely. The result on a calendar-bound view was a Timeline the + * switcher offered and the renderer bucketed wholly into "No date" (objectui#3129). + * + * What stays here is the view's OWN declared config, floored at `'name'`. An + * object-level `objectDef.titleField` leg used to sit in the middle of that + * chain; it was removed in objectui#6557 because `@objectstack/spec`'s object + * schema is a `strictObject` that REJECTS the key with `unrecognized_keys`, so + * no legal object metadata could ever reach it (objectui#6531 established the + * measurement, and dropped the twin read inside `getRecordDisplayName`). + * + * ⚠️ WHAT THE SIBLING BRANCHES BELOW ACTUALLY DO (`5f4514f7b`). The sentence + * above used to end "— the same two-rung shape the calendar and gantt branches + * below already use", and for gantt that was FALSE the whole time it stood: the + * gantt branch floored `startDateField` / `endDateField` at `'start_date'` / + * `'end_date'`, which is precisely the one-rung fabrication this note declares + * retired. A note is the authority the next fixer consults, so it vouched for + * the lines that were broken. Each branch is therefore stated as MEASURED, not + * as a family: + * + * - `calendarViewOptions` below — forwards a declared block or emits no + * `calendar` key at all; it never floors a date axis (objectui#7029). + * - `ganttViewOptions` below — forwards the declared block, title floored at + * `'name'`, and invents neither date field. Its `'start_date'` / `'end_date'` + * floors were deleted by `5f4514f7b`; that is what makes `ObjectGantt`'s + * own "Gantt configuration required" screen reachable from this route. + * - the TIMELINE axis at the two SIBLING FACES — `plugin-list/ListView.tsx` + * and `plugin-view/ObjectView.tsx`. Both floored `startDateField` at + * `'created_at'`, the very literal objectui#3129 retired HERE, and + * `ListView` carried it as a stated DECISION ("`created_at` stays the last + * resort for a view that declares no date axis anywhere") — two faces + * holding documented and OPPOSITE postures on one field name. That is what + * `5f4514f7b` routed to a single ruling instead of settling per-face, and + * the ruling (2026-09-01, 总监批 #28) answered it as house posture: + * 日期轴永不虚构 — a date axis is never fabricated. Its step ③ deleted both + * floors, so all three faces now forward a declared axis or none, and + * `ObjectTimeline`'s own refusal screen (step ①, objectui#7459) is reachable + * from every one of them. This note no longer describes only THIS face. + * + * Exported for the regression suite. + */ +export function timelineViewOptions(viewDef: any): Record { + // The spec key only. objectui#6152 round 14 retired the pre-#2231 + // `timeline.dateField` rung: every list-view door refuses the alias by name, + // and `ListView` no longer reads it either. + const declaredStart = viewDef?.timeline?.startDateField; + return { + // Spread the full view-defined timeline config first so the spec fields + // (startDateField/endDateField/groupByField/colorField/scale) survive. + ...(viewDef?.timeline || {}), + // Only ever restate a binding the view actually declared. + ...(declaredStart ? { startDateField: declaredStart } : {}), + titleField: viewDef?.timeline?.titleField || 'name', + // objectui#6152 round 12: no `descriptionField`. The spec's timeline block + // has no such member, the bag refuses it, and nothing drew it: it landed + // only in the generated node's nested block, which `ObjectTimeline` never + // reads for a description (measured on objectui#6152, report 6070437445). + }; +} + +/** + * The `options.calendar` config this page hands to `ListView` — or NOTHING. + * + * objectui#7029 (ruled on objectstack#13748, director batch #19, option A). + * This face used to fabricate `startDateField: 'due_date'` and + * `titleField: 'name'` for EVERY object view, declared or not. Downstream that + * is indistinguishable from a real binding, and it is what made the calendar + * renderer's own refusal screen ("Calendar configuration required…", + * `ObjectCalendar.tsx`) unreachable from this route: the renderer decides by + * asking whether a start-date binding is PRESENT, and this face always said + * yes. Measured on hotcrm's `crm_leave_request` (real fields `start_date` / + * `end_date`, no `calendar:` block): all nine records piled onto today's cell + * with titles resolved through the display-name chain — a plausible, fully + * wrong screen with zero signal to the author. + * + * The same fabrication also fed two gates that read this bag: + * `ListView.availableViews` offered the Calendar toggle for every object view, + * and `resolveTimelineDateBinding` accepts a calendar binding as a legitimate + * timeline axis — so the invented name silently answered for the Timeline + * switcher too. + * + * What stays is the view's OWN declared block, forwarded verbatim (spread, not + * whitelisted, so every spec key survives), and `undefined` when the view + * declared none. Exactly the shape the sibling faces already converged on: + * `timelineViewOptions` above (objectui#3129 retired this very literal from the + * timeline axis), the kanban branch's `detectStatusField` (ADR-0085, "never + * invents a field the object doesn't have"), and `defaultCalendarFromObject` + * in `InterfaceListPage` (a binding or `undefined`, never a guess). + * + * ⚠️ A view that carried no `calendar:` block and happened to sit on an object + * with a real `due_date` field was rendering by luck; it now reaches the + * refusal screen instead. That is the ruled loud-over-silent direction. + * + * Exported for the regression suite. + */ +export function calendarViewOptions(viewDef: any): Record | undefined { + const declared = viewDef?.calendar; + if (!declared || typeof declared !== 'object') return undefined; + // Forward what the author wrote — nothing invented, nothing floored. + return { ...declared }; +} + +/** + * The `options.gantt` config this page hands to `ListView`. + * + * `5f4514f7b` — the same class objectui#7029 removed from the calendar branch, + * reported separately by PR #7062 rather than fixed alongside it. This face used + * to floor `startDateField` at `'start_date'` and `endDateField` at `'end_date'` + * for EVERY object view, declared or not — field names no view had written and + * most objects do not carry. Downstream that is indistinguishable from a real + * binding, and it is what made `ObjectGantt`'s own refusal screen ("Gantt + * configuration required. Please specify startDateField, endDateField, and + * titleField.") unreachable from this route: `getGanttConfig` takes its flat + * branch as soon as BOTH date props are present, and this face always supplied + * both. It also answered the ADR-0047 capability gate in `ListView.availableViews` + * (`schema.options?.gantt?.startDateField`), so the Gantt toggle was live on + * every object view in the product. + * + * ⚠️ MEASURED BEFORE THE DELETION, because #7029's mechanic is only correct + * where a refusal path exists: `ObjectGantt` REFUSES an absent binding — it does + * not render empty and does not throw. Pinned as the seam in + * `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`. + * + * What stays is the view's OWN declared block, spread whole so every spec key + * the renderer reads survives (parentField/typeField for the summary→step + * hierarchy, baseline*, groupByField, resourceView/assignee*, tooltipFields, + * quickFilters, …) — a bare whitelist here once dropped every field past + * colorField and flattened the chart. `titleField` keeps its `'name'` floor: + * that is a display-name default, not a date axis, and it is the same rung + * `timelineViewOptions` above carries. + * + * Exported for the regression suite. + */ +export function ganttViewOptions(viewDef: any): Record { + return { + ...(viewDef?.gantt || {}), + // Only ever restates what the view declared — no date field is floored. + titleField: viewDef?.gantt?.titleField || 'name', + }; +} + +/** + * The `options.gallery` config this page hands to `ListView`. + * + * objectui#7547 — the last surviving member of the objectui#7029 / `5f4514f7b` / + * #7500 class on this face. It floored `imageField` at `'image'` for EVERY + * object view, declared or not: a cover binding no view had written, on a field + * name most objects do not carry. + * + * ⚠️ WHAT THAT LITERAL ACTUALLY DID, which is not what it looks like. The + * screen damage was small — `ObjectGallery` resolves the cover per record and + * COLLAPSES the cover area when no record yields one, so an invented `'image'` + * degraded to a gallery of coverless cards. The damage was to the GATE. This + * bag feeds `ListView.availableViews`, whose gallery check reads + * `schema.options?.gallery?.imageField`; a value that is always present makes + * that check answer YES for every object view that whitelists `gallery`, so + * ADR-0047's whitelist ∩ resolvable — the mechanism that exists to stop a + * visualization being offered with nothing behind it — was answering about a + * name this file wrote rather than one the author did. Gallery was offered + * without a block, and stayed watchable only because the renderer happens to + * degrade politely. That is a coincidence, not a design, and it is the same + * second-order effect objectui#7029 measured for `calendar` and `5f4514f7b` + * for `gantt`. + * + * ⚠️ MEASURED BEFORE THE DELETION, the discipline `5f4514f7b` wrote down: + * #7029's mechanic — delete the literal, let the read site answer — is only + * correct where the read site's answer is honest. `ObjectGallery` floors its + * own `coverField` at `'image'` too, and that rung STAYS: it is the component's + * own decision about an unconfigured record, and it is honest because the cover + * area collapses when the guess yields nothing. What this face must not do is + * pre-empt that decision and light the capability gate on the way past. + * + * What stays here is the view's OWN declared block, spread whole so every spec + * key survives (`cardSize` / `visibleFields` / `coverFit` / …), the cover + * binding under `coverField` BUT ONLY WHEN ONE WAS DECLARED, and the + * `titleField` `'name'` floor — a display-name default, not a binding, exactly + * as `ganttViewOptions` above keeps it. + * + * objectui#6152 round 12 (seat answer Q1 → A): the legacy `imageField` is no + * longer written beside `coverField`. `@object-ui/types` judges this bag as the + * spec's list overlay judges it, and that refuses `options.gallery.imageField` + * by name. The gate the paragraph above describes learned + * `options.gallery.coverField` in the same round, so the toggle still lights + * from what this writes and still stays dark when the view declared no cover. + * + * objectui#6152 round 14: the legacy READ is retired too. The cover binding is + * read from the spec's `coverField` alone, so a stored `gallery.imageField` + * binds no cover here, offers no Gallery toggle, and leaves `ObjectGallery` on + * its own `'image'` floor. + * + * Exported for the regression suite. + */ +export function galleryViewOptions(viewDef: any): Record { + const gallery = viewDef?.gallery; + // The cover binding goes out under the spec's `coverField` ONLY, read from + // `coverField` ONLY, and is never invented. objectui#6152 round 12 dropped the + // legacy `imageField` this used to cross-fill beside it, and round 14 retired + // the alias rung that still READ it. + const declaredCover = gallery?.coverField; + return { + ...(gallery || {}), + ...(declaredCover ? { coverField: declaredCover } : {}), + // Spelled through `viewDef` rather than the `gallery` local above, and + // that is load-bearing: `ObjectView.titleFieldConvergence.test.tsx` + // (objectui#6557) pins all six `titleField` / `labelField` seams to ONE + // expression shape — a chain of VIEW-declared rungs floored at 'name', + // with `viewDef` as the mechanical proxy for "view-declared". Reading + // the local here still resolves the same value but blinds that scan to + // the thing it hunts, so the seam keeps the family spelling. + titleField: viewDef?.gallery?.titleField || 'name', + }; +} + +/** + * The view-level KANBAN block this face forwards — the fifth member of the + * `timelineViewOptions` / `calendarViewOptions` / `ganttViewOptions` / + * `galleryViewOptions` family above, and the last one still inlined as an + * anonymous IIFE inside the schema literal (objectui#8193). + * + * ⭐ THE LANE KEY IS `groupByField`, THE SPEC'S OWN SPELLING. This used to emit + * the legacy `groupField` alias instead — never the canonical key, even though + * it already READ the canonical key first. The sibling producer + * `defaultKanbanFromObject` (`InterfaceListPage.tsx`) had migrated already and + * left the reasoning next to itself — "that read-site now prefers the spec key, + * so one key is enough" — but this twin twelve hundred lines away was not + * carried along, so ONE producer surface spoke two vocabularies for one concept + * depending on which entry point you arrived through. + * + * ⚠️ objectui#8193 changed only what this face WRITES; the alias READS stayed + * until objectui#6152 round 14 retired them here and in `ListView` alike. The + * lane is read from `groupByField` alone now, so a stored `kanban.groupField` + * no longer names it: the lane falls to the object's declared lifecycle, as for + * a view that names none. + * + * ⚠️ WRITING THE CANONICAL KEY HERE REQUIRED THE GATE TO LEARN IT, and that is + * the half a reader will not guess from this file. `ListView.availableViews` + * consulted the `options.` bag for the LEGACY spelling only + * (`schema.options?.kanban?.groupField`), so emitting `groupByField` alone made + * the Kanban capability stop resolving and the toggle vanish from every object + * view — measured, not reasoned: `{groupBy, groupField}` offered Kanban, and + * `{groupBy, groupByField}` did not. The gate now reads both spellings out of + * the bag, the same one-question-two-sites repair objectui#5042 made for `map` + * and objectui#7544 for `chart`. Pinned in `ObjectView.kanbanLane-8193` here + * and in `ListView.kanbanOptionsBagCanonical-8193` in `plugin-list`. + * + * ⭐ `groupBy` IS RETIRED HERE (objectui#8213) — the THIRD spelling of the one + * concept this expression already writes canonically. Measured against + * `@objectstack/spec` 17.3.0's strict `KanbanConfigSchema`, which declares + * `groupByField` / `summarizeField` / `columns` and refuses `groupBy` BY NAME, + * on a call where one control fires (`zzzBogusKey` is refused by name) and + * another accepts (`groupByField` alone draws no `unrecognized_keys`). Upstream + * knows the OTHER alias by name — probing `groupField` answers "Did you mean + * `groupField` → `groupByField`?" — and knows nothing at all about `groupBy`. + * + * ⚠️ THE PRODUCER CENSUS THE CARD MADE A PRECONDITION, run before the deletion. + * A structural scan of every tracked file for a bare `groupBy` inside a + * view-level `kanban`/`calendar`/`gallery`/`timeline`/`gantt` object literal + * found NO runtime producer but this one; the lit control on the same + * instrument (`groupByField` in the same position) returned 58 sites, so the + * empty reading is a reading. The only other authors are FIXTURES: the two arms + * of `ListView.kanbanOptionsBagCanonical-8193` that pin this very bag, and one + * stale metadata payload in `data-objectstack`'s `updateView.draft.test.ts`. + * The sibling producer `defaultKanbanFromObject` emits `{ groupByField }` alone + * and always has. + * + * ⚠️ WHY THE ONE READER WAS UNHARMED (as measured then). `ListView`'s two + * projection/expand collectors listed `v.groupByField, v.groupField, v.groupBy` + * as candidates for the same lane value, so `groupBy` did contribute a field + * NAME to the query projection — and this same expression writes that + * identical value under `groupByField`, which those collectors read first. The + * capability gate never read `groupBy` at all, and neither did the render + * branch. Since objectui#6152 round 14 the collectors, the gate and the render + * branch read `groupByField` alone. + * + * ⚠️ WHAT THIS CLOSED AND WHAT IT DID NOT — and what has since closed the rest. + * `ListView`'s kanban branch destructured + * `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the + * merged config and spread the REST *after* its own `groupBy: laneField`, so a + * surviving `groupBy` overrode the lane it had just resolved. This deletion + * removed the only producer in this repo that fed that override; the override + * itself stayed reachable from an author-written `kanban.groupBy` riding this + * repo's `.passthrough()` mirror, and was carried on its own card. + * ⭐ THAT CARD HAS LANDED (objectui#8365, maintainer ruling of 2026-09-12 — + * decision batch #117 item 5, option B): `groupBy` is now stripped in + * `ListView`'s destructure, so the canonical lane wins, AND the view-level + * `KanbanConfig` mirror (`@object-ui/types`, `zod/objectql.zod.ts`) declares it + * as an alias refusal naming `groupByField`, so the key is refused BY NAME at + * the read door instead of riding the passthrough. ⛔ Do not re-file it. + * + * ⭐ THE CARD FIELDS GO OUT AS `columns`, THE SPEC'S SPELLING (objectui#6152 + * round 12, seat answer Q1 → A). This wrote the legacy `cardFields` alias, which + * objectui#8213 left as a known residual. `@object-ui/types` now judges this bag + * as the spec's list overlay does and refuses `options.kanban.cardFields` by + * name, and `ListView`'s kanban branch reads `columns` for the cards when no + * `cardFields` is present: measured on objectui#6152 (report 6070437445), the + * generated node and the `$select` are identical under either spelling. + * `titleField` is a member of the spec's kanban block (measured on 17.7.0). Every key this + * expression writes is therefore a spec key, and + * `ObjectView.kanbanGroupByRetired-8213` pins that, so an undeclared key + * reddens instead of joining them quietly. + * + * ADR-0085: when the view doesn't pick a lane field, the object's declared + * lifecycle (`stageField`) decides — including the strict `stageField: false` + * suppression (no default lanes; the status-shaped field is declared + * non-linear) and the shared name/type heuristic, which never invents a field + * the object doesn't have (the old hard-coded 'status' did). + * + * objectui#11629: `summarizeField` (the spec's "Field to sum at top of column") + * is relayed only when the view declares it. This block is the only kanban + * config `ListView` receives on this page when the stored row carries no + * `options.kanban` bag. Without the relay, the key never reached `ListView`'s + * projection or the board's column headers, so a view that declared it + * rendered counts with no totals. Like the lane, an absent key stays absent: + * no default field is invented. + * + * Exported for the pin test. + */ +export function kanbanViewOptions(viewDef: any, objectDef: any): Record { + const lane = + viewDef?.kanban?.groupByField || + detectStatusField(objectDef as any) || + undefined; + const summarizeField = viewDef?.kanban?.summarizeField; + return { + ...(lane ? { groupByField: lane } : {}), + titleField: viewDef?.kanban?.titleField || 'name', + // The spec's own spelling of the card fields (objectui#6152 round 12): the + // bag refuses the legacy `cardFields` this used to write, and `ListView` + // reads `columns` for the cards, so the board is unchanged. + columns: viewDef?.kanban?.columns, + ...(summarizeField ? { summarizeField } : {}), + }; +} + +/** + * objectui#10380 — the stored row's legacy `options` bag, as this page hands it + * to `ListView`. A row with no bag (or a bag that is not an object) gives `{}`. + * + * `@objectstack/spec`'s flattened list overlay declares this bag + * (`ListViewOverlayOptionsSchema`). The view write door judges each + * `options.KIND` block key by key with that kind's own block schema, refuses + * an out-of-contract key by name, and stores what it accepts. The spec + * describes the bag as an underlay: the top-level `KIND` block "wins per key + * where both set one". The interface page has always forwarded the bag, so one + * stored row rendered two ways. Ruling A on objectui#10380 (comment + * 5824043998) keeps that forward ("what passes the door is legal"), so the + * object page now forwards the bag too. + * + * The relay below does three things with it, and none of them merges keys: + * - `options` carries the bag. For each kind the bag carries, the bag's block + * REPLACES this page's synthesized block (`kanbanViewOptions` and its + * siblings). This is the rule `InterfaceListPage` applies to its `mapCfg` + * (`view.options.map ?? derived`). A synthesized block stands in only for a + * kind the bag does not carry, so a row with no bag is relayed exactly as + * before. + * - For each kind the bag carries, the view's own top-level block also goes + * out at the top level, where `InterfaceListPage` puts it as well. + * - `ListView` then lays the top-level block over the bag per key. That merge + * already exists in each render branch (`{ ...options.KIND, ...KIND }`), + * and it is the spec's precedence. ⛔ No second merge is written here. + * + * Exported for the pin test. + */ +export function storedLegacyOptions(viewDef: unknown): Record { + const bag = viewDef && typeof viewDef === 'object' ? (viewDef as { options?: unknown }).options : undefined; + return bag && typeof bag === 'object' && !Array.isArray(bag) ? (bag as Record) : {}; +} + +/** + * objectui#10046 — is `a` the same `options` bag as `b`, compared by VALUE? + * (objectui#7237 holds the resolved `filter` through the same comparison.) + * + * `renderListView` rebuilds `fullSchema.options` as a fresh object literal on + * every call, and `plugin-view`'s `ObjectView` calls it on every one of its + * renders. `ListView` names `schema.options` BY IDENTITY in its fetch effect's + * dependency list (objectui#4567 ruled that dependency correct and put + * stabilisation at the PRODUCER), so each host re-render that changed nothing + * re-issued the identical `dataSource.find`: one extra at mount (the record + * count landing), two on opening a record and two on closing it. + * + * A copy of `plugin-view`'s `isStructurallyEqual` (`stableIdentity.ts`, + * objectui#6460), kept module-local because that one is not exported and + * exporting it would publish a new name. Same safety invariant: every + * uncertainty (an unmodelled type — function, `Map`, class instance —, a + * differing key count, a structure deeper than the bound) answers "not + * equal", which hands `ListView` a new identity and therefore a refetch. It can + * only ever remove a REDUNDANT query; it can never withhold a needed one. + * ⛔ Not `JSON.stringify`: that calls `{ a: undefined }` and `{}` equal (a + * missed refetch) and throws on a cycle. + */ +export function isSameOptionsValue(a: unknown, b: unknown, depth = 0): boolean { + if (Object.is(a, b)) return true; + if (depth >= 12) return false; + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, i) => isSameOptionsValue(item, b[i], depth + 1)); + } + const isPlain = (v: unknown): v is Record => { + if (v === null || typeof v !== 'object') return false; + const proto = Object.getPrototypeOf(v); + return proto === Object.prototype || proto === null; + }; + if (isPlain(a) && isPlain(b)) { + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isSameOptionsValue(a[k], b[k], depth + 1)); + } + return false; +} + +/** + * Issue one `updateView` per patch — **as a method call on the adapter** + * (objectui#4463). + * + * This exists to make one specific bug unwritable. `handleSetDefaultView` and + * `handleReorderViews` both used to detach the method before calling it (the + * reorder no longer writes a view at all since objectui#12418 — it is the + * user's preference): + * + * ```ts + * const updateView = (dataSource as any).updateView; // detached + * … + * updateView(objectName, target, patch); // `this` === undefined + * ``` + * + * The adapter's `updateView` opens with `await this.connect()`, so every write + * in the list rejected with `TypeError: Cannot read properties of undefined + * (reading 'connect')` **before any request was issued** — the user got a toast + * and the server never heard about it. "Set as Default" was dead on every saved + * view for exactly this reason, while rename and pin worked from the same menu: + * `handlePinView` always called it as a method, which is the in-file convention + * this restores. + * + * The call site routes through here, so there is a single place in the file + * that calls `updateView` over a patch list — and it has no local to detach into. + */ +export function dispatchViewPatches( + dataSource: any, + objectName: string, + patches: ReadonlyArray<{ viewId: string; patch: Record }>, +): Array> { + return patches.map( + ({ viewId, patch }) => dataSource.updateView(objectName, viewId, patch), + ); +} diff --git a/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts b/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts index f2e408446a3..175181f9384 100644 --- a/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts +++ b/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts @@ -83,7 +83,7 @@ const LEDGER: Row[] = [ reason: '"Save as view": the refusal is a toast with the door\'s message, and the dialog stays open (objectui#11578).', }, { - file: 'ObjectView.tsx', symbol: 'dispatchViewPatches', callee: 'updateView', calls: 1, + file: 'objectViewOptions.ts', symbol: 'dispatchViewPatches', callee: 'updateView', calls: 1, kind: 'relays', reason: 'Issues one `updateView` per patch and hands every promise back; set-as-default and reorder await them and answer for the refusal.', }, From 668d82f977e3e7aff8f549a44fb4388869c4e4f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:40:59 +0000 Subject: [PATCH 02/11] refactor(app-shell): move ObjectView's leaf pieces into objectViewChrome.tsx (slice 3) ObjectNotFoundState, the saved-view tab strip's viewport check (VIEW_TABS_MIN_WIDTH, viewTabsFitNow, useViewTabsFit) and keepInlineEditModeForTheSession move verbatim out of views/ObjectView.tsx into views/objectViewChrome.tsx. ObjectView.tsx imports them back and re-exports ObjectNotFoundState, the one name it exported. No test edits. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .changeset/25992-objectview-chrome-split.md | 12 +++ .../app-shell/src/views/ObjectView.tsx | 95 ++--------------- .../app-shell/src/views/objectViewChrome.tsx | 100 ++++++++++++++++++ 3 files changed, 118 insertions(+), 89 deletions(-) create mode 100644 .changeset/25992-objectview-chrome-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewChrome.tsx diff --git a/.changeset/25992-objectview-chrome-split.md b/.changeset/25992-objectview-chrome-split.md new file mode 100644 index 00000000000..c1265ee3644 --- /dev/null +++ b/.changeset/25992-objectview-chrome-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s leaf pieces move into a sibling module + +Clause-②: no +Metadata-protocol: none + +The "object not found" state (`ObjectNotFoundState`), the viewport check that decides whether the saved-view tab strip is drawn, and the inline-edit toggle's session-only callback move verbatim out of `views/ObjectView.tsx` into `objectViewChrome.tsx` beside it. No logic, name or rendered output changes. `ObjectView.tsx` re-exports every name it exported before, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index c80dfb5da63..455555f6f6d 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -49,7 +49,7 @@ import { EmptyDescription, NavigationOverlay, } from '@object-ui/components'; -import { Plus, Upload, Table as TableIcon, Lock, MoreHorizontal, RotateCcw } from 'lucide-react'; +import { Plus, Upload, Lock, MoreHorizontal, RotateCcw } from 'lucide-react'; import { FavoriteMenuItem, useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; @@ -125,6 +125,7 @@ import { isSameOptionsValue, dispatchViewPatches, } from './objectViewOptions.js'; +import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -155,6 +156,10 @@ export { dispatchViewPatches, } from './objectViewOptions.js'; +// objectstack#25992 slice 3: `ObjectNotFoundState` moved to `./objectViewChrome.tsx` +// and stays exported from here (`AppContent` imports it by this path). +export { ObjectNotFoundState } from './objectViewChrome.js'; + /** * Props of the Console `ObjectView`, the route-level object surface * `@object-ui/app-shell` exports (objectui#7483). @@ -186,94 +191,6 @@ export interface ConsoleObjectViewProps { externalRefreshKey?: number; } -/** - * The "object not found" state the route draws for an object it has no - * definition of. objectui#13052 — `packageDisabled`: the server answered that - * the object exists and its package is disabled (`OBJECT_PACKAGE_DISABLED`), - * so the description says that instead. The console route draws it for that - * answer (`AppContent`); this module's `ObjectView` draws it for an absence. - * Not part of the package entry. - */ -export function ObjectNotFoundState({ objectName, packageDisabled = false }: { objectName: string | undefined; packageDisabled?: boolean }) { - const { t } = useObjectTranslation(); - return ( -
- -
- -
- {t('console.objectView.objectNotFound')} - - {packageDisabled - ? t('console.objectView.objectPackageDisabled', { objectName }) - : t('console.objectView.objectNotFoundDescription', { objectName })} - {' '} - {t('console.objectView.objectNotFoundHint')} - -
-
- ); -} - -/** - * The list toolbar's inline-edit toggle, as this page wires it: it writes - * nothing (objectui#5144, triage's ruling E). - * - * The view's `inlineEdit` and `userActions.editInline` are the AUTHOR's - * permission keys, and `normalizeListViewSchema` folds the first into the - * second. `editInline` is on by default since the v18 ruling (objectstack#22605, - * objectui#12086), so a view that declares neither key offers the toggle, and - * `editInline: false` withdraws it. This toggle used to persist a USER's edit - * mode into `inlineEdit` through `persistViewPatch`. After the fold, switching - * it off stored `inlineEdit: false`, which reads as "not offered", so the - * toggle was gone from the next load and nothing in the console could bring it - * back. - * - * The edit mode is now session state. `ListView` keeps it, and seeds it from - * the view's `inlineEdit` on each load. The callback stays wired because - * `ListView` offers the wide toolbar toggle only to a host that wires one. - * - * Recorded costs: the edit mode is not remembered across loads, and an overlay - * that already stores `inlineEdit: false` still reads off. That is existing - * data, and the maintainer's ruling rejects migrating it. Declaring - * `userActions.editInline: true` on such a view brings the toggle back. - */ -/** - * Tailwind's `sm` breakpoint (px): from here up the list page draws its - * saved-view tabs in the list toolbar; below it the app bar's mobile view - * switcher is the page's only view control (objectui#12440). - */ -const VIEW_TABS_MIN_WIDTH = 640; - -/** - * Is the viewport at least {@link VIEW_TABS_MIN_WIDTH} wide? The matchMedia - * idiom of `useIsMobile` / `useIsWideViewport`, except the first answer is - * read synchronously: the tab strip is the first thing on the page's list row, - * and resolving it in an effect would draw the row once without it and then - * jump. objectui#12440 — this decides whether the strip is HANDED to the list - * at all, so below `sm` nothing is passed and nothing is drawn (the CSS - * `hidden sm:block` it replaces only hid it). - */ -function viewTabsFitNow(): boolean { - return typeof window !== 'undefined' && window.innerWidth >= VIEW_TABS_MIN_WIDTH; -} - -function useViewTabsFit(): boolean { - const [fits, setFits] = useState(viewTabsFitNow); - useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; - const mql = window.matchMedia(`(min-width: ${VIEW_TABS_MIN_WIDTH}px)`); - const onChange = () => setFits(viewTabsFitNow()); - mql.addEventListener('change', onChange); - return () => mql.removeEventListener('change', onChange); - }, []); - return fits; -} - -function keepInlineEditModeForTheSession(): void { - // Deliberately empty: see the docblock. -} - export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: ConsoleObjectViewProps) { const { objectName } = useParams(); diff --git a/ui/packages/app-shell/src/views/objectViewChrome.tsx b/ui/packages/app-shell/src/views/objectViewChrome.tsx new file mode 100644 index 00000000000..a8ad49dfa90 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewChrome.tsx @@ -0,0 +1,100 @@ +/** + * Leaf pieces of the Console `ObjectView` (`./ObjectView.tsx`): the "object + * not found" state, the viewport check that decides whether the saved-view tab + * strip is drawn, and the inline-edit toggle's session-only callback, moved out + * of that file verbatim (objectstack#25992, slice 3). `ObjectView.tsx` + * re-exports every name it exported before. + */ + +import { useState, useEffect } from 'react'; +import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Table as TableIcon } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; + +/** + * The "object not found" state the route draws for an object it has no + * definition of. objectui#13052 — `packageDisabled`: the server answered that + * the object exists and its package is disabled (`OBJECT_PACKAGE_DISABLED`), + * so the description says that instead. The console route draws it for that + * answer (`AppContent`); this module's `ObjectView` draws it for an absence. + * Not part of the package entry. + */ +export function ObjectNotFoundState({ objectName, packageDisabled = false }: { objectName: string | undefined; packageDisabled?: boolean }) { + const { t } = useObjectTranslation(); + return ( +
+ +
+ +
+ {t('console.objectView.objectNotFound')} + + {packageDisabled + ? t('console.objectView.objectPackageDisabled', { objectName }) + : t('console.objectView.objectNotFoundDescription', { objectName })} + {' '} + {t('console.objectView.objectNotFoundHint')} + +
+
+ ); +} + +/** + * The list toolbar's inline-edit toggle, as this page wires it: it writes + * nothing (objectui#5144, triage's ruling E). + * + * The view's `inlineEdit` and `userActions.editInline` are the AUTHOR's + * permission keys, and `normalizeListViewSchema` folds the first into the + * second. `editInline` is on by default since the v18 ruling (objectstack#22605, + * objectui#12086), so a view that declares neither key offers the toggle, and + * `editInline: false` withdraws it. This toggle used to persist a USER's edit + * mode into `inlineEdit` through `persistViewPatch`. After the fold, switching + * it off stored `inlineEdit: false`, which reads as "not offered", so the + * toggle was gone from the next load and nothing in the console could bring it + * back. + * + * The edit mode is now session state. `ListView` keeps it, and seeds it from + * the view's `inlineEdit` on each load. The callback stays wired because + * `ListView` offers the wide toolbar toggle only to a host that wires one. + * + * Recorded costs: the edit mode is not remembered across loads, and an overlay + * that already stores `inlineEdit: false` still reads off. That is existing + * data, and the maintainer's ruling rejects migrating it. Declaring + * `userActions.editInline: true` on such a view brings the toggle back. + */ +/** + * Tailwind's `sm` breakpoint (px): from here up the list page draws its + * saved-view tabs in the list toolbar; below it the app bar's mobile view + * switcher is the page's only view control (objectui#12440). + */ +const VIEW_TABS_MIN_WIDTH = 640; + +/** + * Is the viewport at least {@link VIEW_TABS_MIN_WIDTH} wide? The matchMedia + * idiom of `useIsMobile` / `useIsWideViewport`, except the first answer is + * read synchronously: the tab strip is the first thing on the page's list row, + * and resolving it in an effect would draw the row once without it and then + * jump. objectui#12440 — this decides whether the strip is HANDED to the list + * at all, so below `sm` nothing is passed and nothing is drawn (the CSS + * `hidden sm:block` it replaces only hid it). + */ +function viewTabsFitNow(): boolean { + return typeof window !== 'undefined' && window.innerWidth >= VIEW_TABS_MIN_WIDTH; +} + +export function useViewTabsFit(): boolean { + const [fits, setFits] = useState(viewTabsFitNow); + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mql = window.matchMedia(`(min-width: ${VIEW_TABS_MIN_WIDTH}px)`); + const onChange = () => setFits(viewTabsFitNow()); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, []); + return fits; +} + +export function keepInlineEditModeForTheSession(): void { + // Deliberately empty: see the docblock. +} From 2e1145243bd779b5f02f06d4304bc606a7c930a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:57:04 +0000 Subject: [PATCH 03/11] refactor(app-shell): move ObjectView's realtime refresh and record count into a sibling hook Slice 4 of the move-only ObjectView split. The realtime subscription, the reconnection conflict resolution and the footer record-count fetch move verbatim from ObjectViewInner into useObjectViewLiveData in views/objectViewLiveData.ts. The component calls the hook at the same position (hook order unchanged) and passes its inputs explicitly; the recordCount state stays in the component. No test edits. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .../25992-objectview-live-data-split.md | 12 ++++ .../app-shell/src/views/ObjectView.tsx | 56 ++------------- .../app-shell/src/views/objectViewLiveData.ts | 71 +++++++++++++++++++ 3 files changed, 87 insertions(+), 52 deletions(-) create mode 100644 .changeset/25992-objectview-live-data-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewLiveData.ts diff --git a/.changeset/25992-objectview-live-data-split.md b/.changeset/25992-objectview-live-data-split.md new file mode 100644 index 00000000000..03c85461ea0 --- /dev/null +++ b/.changeset/25992-objectview-live-data-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s realtime refresh and record-count fetch move into a sibling hook + +Clause-②: no +Metadata-protocol: none + +The realtime subscription that refreshes the list when the server reports a change, the reconnection conflict resolution, and the footer's `$top: 0` record-count fetch move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx` into `useObjectViewLiveData` in `objectViewLiveData.ts` beside it. The component calls the hook at the same place, so the hook order is unchanged; the record-count state stays in the component and is passed in with the other inputs. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 455555f6f6d..6676b3568e0 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -89,7 +89,6 @@ import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration'; import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope, useMetadata } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; @@ -126,6 +125,7 @@ import { dispatchViewPatches, } from './objectViewOptions.js'; import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import { useObjectViewLiveData } from './objectViewLiveData.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -1449,57 +1449,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co onToast: toastHandler as (message: string, options?: { type?: string }) => void, }); - // Real-time: auto-refresh when server reports data changes - const { lastMessage: realtimeMessage } = useRealtimeSubscription({ - channel: `object:${objectDef.name}`, - }); - - // Conflict resolution: detect and queue conflicts on reconnection - const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user'; - const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId); - - useEffect(() => { - if (realtimeMessage) { - // On reconnection data change, auto-resolve with server-wins strategy - if (hasConflicts) { - resolveAllConflicts('remote'); - } - refreshData(); - } - }, [realtimeMessage, hasConflicts, resolveAllConflicts, refreshData]); - - // Fetch record count for footer display. - // - // `$top: 0` IS the request: give me the total, send no rows. It was written - // `limit: 0`, which is not a `QueryParams` key — `convertQueryParams` copies - // exactly the `$`-prefixed keys the type declares — so the cap reached no - // branch and was dropped, and the platform's GET list route has no default - // page size. The effect therefore did the opposite of what it says: it - // downloaded EVERY row of the object, on every mount and every refresh of - // every list view, to read one integer off the envelope. The dead spelling - // type-checked because `QueryParams` then carried `[key: string]: any` for - // adapter-specific params; `object-ui/no-unprefixed-query-params` rejects it - // at write time now (objectui#5458), and the index signature itself is gone - // (objectui#7497), so `tsc` refuses it as well. - // - // `total` is the only field that can still answer the question, so the - // row-counting fallbacks are gone rather than repointed. Once we ask for - // zero rows an empty `data` means "you asked for none", not "the object is - // empty" — counting the response would report a confident `0` for any - // adapter that does not send a total. Leaving `recordCount` `undefined` - // omits the footer line instead of asserting a wrong number; the render is - // already guarded by `typeof recordCount === 'number'`. - useEffect(() => { - if (dataSource?.find && objectDef.name) { - dataSource.find(objectDef.name, { $top: 0 }).then((result: any) => { - if (typeof result?.total === 'number') { - setRecordCount(result.total); - } - }).catch(() => { - // Silently ignore — record count is non-critical - }); - } - }, [dataSource, objectDef.name, refreshKey]); + // objectstack#25992 slice 4: the realtime refresh, conflict resolution and + // footer record count moved to `./objectViewLiveData.ts`; the count state stays here. + useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }); // Navigation overlay for record detail (supports drawer/modal/split/popover via config) // Priority: activeView.navigation > objectDef.navigation > default drawer. diff --git a/ui/packages/app-shell/src/views/objectViewLiveData.ts b/ui/packages/app-shell/src/views/objectViewLiveData.ts new file mode 100644 index 00000000000..aea1875e63c --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewLiveData.ts @@ -0,0 +1,71 @@ +/** + * Live data of the Console `ObjectView` (`./ObjectView.tsx`): the realtime + * subscription that refreshes the list when the server reports a change, the + * reconnection conflict resolution, and the footer's record-count fetch, moved + * out of `ObjectViewInner` verbatim (objectstack#25992, slice 4). The record + * count state stays in the component; it and every other input are passed in. + */ + +import { useEffect } from 'react'; +import type { DataSource } from '@object-ui/types'; +import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration'; + +export function useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }: { + dataSource: DataSource; + objectDef: { name: string }; + refreshData: () => void; + refreshKey: number; + setRecordCount: (count: number) => void; +}) { + // Real-time: auto-refresh when server reports data changes + const { lastMessage: realtimeMessage } = useRealtimeSubscription({ + channel: `object:${objectDef.name}`, + }); + + // Conflict resolution: detect and queue conflicts on reconnection + const conflictUserId = objectDef.name ? `user-${objectDef.name}` : 'current-user'; + const { hasConflicts, resolveAllConflicts } = useConflictResolution(conflictUserId); + + useEffect(() => { + if (realtimeMessage) { + // On reconnection data change, auto-resolve with server-wins strategy + if (hasConflicts) { + resolveAllConflicts('remote'); + } + refreshData(); + } + }, [realtimeMessage, hasConflicts, resolveAllConflicts, refreshData]); + + // Fetch record count for footer display. + // + // `$top: 0` IS the request: give me the total, send no rows. It was written + // `limit: 0`, which is not a `QueryParams` key — `convertQueryParams` copies + // exactly the `$`-prefixed keys the type declares — so the cap reached no + // branch and was dropped, and the platform's GET list route has no default + // page size. The effect therefore did the opposite of what it says: it + // downloaded EVERY row of the object, on every mount and every refresh of + // every list view, to read one integer off the envelope. The dead spelling + // type-checked because `QueryParams` then carried `[key: string]: any` for + // adapter-specific params; `object-ui/no-unprefixed-query-params` rejects it + // at write time now (objectui#5458), and the index signature itself is gone + // (objectui#7497), so `tsc` refuses it as well. + // + // `total` is the only field that can still answer the question, so the + // row-counting fallbacks are gone rather than repointed. Once we ask for + // zero rows an empty `data` means "you asked for none", not "the object is + // empty" — counting the response would report a confident `0` for any + // adapter that does not send a total. Leaving `recordCount` `undefined` + // omits the footer line instead of asserting a wrong number; the render is + // already guarded by `typeof recordCount === 'number'`. + useEffect(() => { + if (dataSource?.find && objectDef.name) { + dataSource.find(objectDef.name, { $top: 0 }).then((result: any) => { + if (typeof result?.total === 'number') { + setRecordCount(result.total); + } + }).catch(() => { + // Silently ignore — record count is non-critical + }); + } + }, [dataSource, objectDef.name, refreshKey]); +} From 5522655ac7909f7f2f001b7c3f034f055f5f48c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:16:51 +0000 Subject: [PATCH 04/11] refactor(app-shell): move ObjectView's identity import and record navigation into sibling hooks Slices 5 and 6 of the move-only ObjectView split, paired in one PR. - The identity-import gate, its password-policy state and data source, and the managed-environment Users marker move verbatim into useObjectViewIdentity in views/objectViewIdentity.tsx. - The record navigation overlay (detail navigation config, drawer recordId URL state, row click, expand to page, list record links) moves verbatim into useObjectViewRecordNavigation in views/objectViewRecordNavigation.ts. The URL equality filters and the user-filter selections between its two blocks stay in the component. Inputs are passed explicitly; the component destructures the returned values under their original names. No test edits. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- ...92-objectview-identity-navigation-split.md | 12 + .../app-shell/src/views/ObjectView.tsx | 267 +++--------------- .../src/views/objectViewIdentity.tsx | 99 +++++++ .../src/views/objectViewRecordNavigation.ts | 211 ++++++++++++++ 4 files changed, 357 insertions(+), 232 deletions(-) create mode 100644 .changeset/25992-objectview-identity-navigation-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewIdentity.tsx create mode 100644 ui/packages/app-shell/src/views/objectViewRecordNavigation.ts diff --git a/.changeset/25992-objectview-identity-navigation-split.md b/.changeset/25992-objectview-identity-navigation-split.md new file mode 100644 index 00000000000..8171d45f97d --- /dev/null +++ b/.changeset/25992-objectview-identity-navigation-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s identity import and record navigation move into sibling hooks + +Clause-②: no +Metadata-protocol: none + +Two concerns move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx`. The identity-import gate, its password-policy state and data source, and the managed-environment Users marker move into `useObjectViewIdentity` in `objectViewIdentity.tsx`. The record navigation overlay (how a row opens its record, the drawer's `?recordId=` URL state, "expand to full page" and the list's record links) moves into `useObjectViewRecordNavigation` in `objectViewRecordNavigation.ts`. The component passes every input in and reads the returned values. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 6676b3568e0..e009c29e060 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -32,9 +32,8 @@ const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })), ); import { ListView } from '@object-ui/plugin-list'; -import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view'; +import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view'; import type { ViewTabItem } from '@object-ui/plugin-view'; -import { RECORD_DRAWER_PARAM } from '../urlParams.js'; // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here. @@ -56,7 +55,7 @@ import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; import { viewTabLabel } from './viewTabLabel.js'; import { getIcon } from '../utils/getIcon.js'; -import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig, ViewNavigationConfig } from '@object-ui/types'; +import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; @@ -75,11 +74,10 @@ import { ManagedByBadge } from '../components/ManagedByBadge.js'; import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; import type { RowCrudPredicates } from '../utils/crudAffordances.js'; -import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, IDENTITY_IMPORT_WRITE_MODES, type IdentityPasswordPolicy } from './identityImport.js'; +import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js'; -import { ManagedUsersInviteLine, MANAGED_USERS_OBJECT, UnauthorizedUserMarker, UserGrantLoader, isManagedEnvironment } from './managedEnvironmentUsers.js'; +import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; -import { useExpressionContext } from '../providers/ExpressionProvider.js'; import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; import { mayReadObject } from '../utils/navObjectReadGate.js'; import { resolveViewId } from '../utils/resolveViewId.js'; @@ -89,7 +87,7 @@ import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope, useMetadata } from '@object-ui/react'; +import { ActionProvider, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js'; @@ -104,7 +102,6 @@ import { selectFilterParams, MOBILE_FAB_LIST_CLEARANCE, } from './objectViewHelpers.js'; -import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; import { defaultListColumnsFromObject, bulkWiredActionNamesKey, @@ -126,6 +123,8 @@ import { } from './objectViewOptions.js'; import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; +import { useObjectViewIdentity } from './objectViewIdentity.js'; +import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -685,71 +684,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // Import wizard open/close state — toolbar entry triggers it. const [showImport, setShowImport] = useState(false); - // ─── Identity import (framework#2782) ──────────────────────────────── - // sys_user's generic import affordance stays FALSE (a data-layer insert - // would bypass better-auth's hashing and never create a credential), but - // platform admins get the same wizard wired to the dedicated - // /api/v1/auth/admin/import-users pipeline. Gated on the deployment's - // admin surface (features.admin from /api/v1/auth/config) plus the - // caller being a workspace admin (the server re-checks, ADR-0068). - // objectui#13330 — and never on a managed environment - // (`features.cloudManaged`): there the server answers `admin/import-users` - // with 409 `RESOURCE_CONFLICT`, because people arrive from the hosting - // organisation through its identity provider. - const { features } = useExpressionContext(); - const isIdentityImport = objectDef.name === IDENTITY_IMPORT_OBJECT; - const identityImportEnabled = - isIdentityImport && features?.admin === true && isAdmin && !isManagedEnvironment(features); - - // ─── Setup → Users on a managed environment (objectui#13330) ───────── - // The line that says where people are invited, and the marker on a user - // who holds no permission set beyond the baseline. The marker reads the - // grant rows through one loader per list (`managedEnvironmentUsers.tsx`), - // rebuilt when the list refreshes, so a grant made elsewhere shows on the - // next load. The metadata store is read through a ref: its context value is - // rebuilt whenever any cached type settles (AGENTS.md #10). - const managedUsers = objectDef.name === MANAGED_USERS_OBJECT && isManagedEnvironment(features); - const metadataStore = useMetadata(); - const metadataStoreRef = useRef(metadataStore); - metadataStoreRef.current = metadataStore; - const grantLoaderRef = useRef<{ refreshKey: number; source: unknown; loader: UserGrantLoader } | null>(null); - if ( - managedUsers && - dataSource && - (grantLoaderRef.current?.refreshKey !== refreshKey || grantLoaderRef.current?.source !== dataSource) - ) { - grantLoaderRef.current = { - refreshKey, - source: dataSource, - loader: new UserGrantLoader(dataSource as any, { - ensureType: (type: string) => metadataStoreRef.current.ensureType(type), - getTypeStatus: (type: string) => metadataStoreRef.current.getTypeStatus?.(type) ?? 'ready', - }), - }; - } - const grantLoader = managedUsers ? grantLoaderRef.current?.loader : undefined; - const managedUserMarker = useCallback( - (row: Record) => { - const id = row?.id ?? row?._id; - if (!grantLoader || id === undefined || id === null || id === '') return null; - return ; - }, - [grantLoader, t], - ); - const [identityPasswordPolicy, setIdentityPasswordPolicy] = useState('auto'); - const identityPolicyRef = useRef('auto'); - identityPolicyRef.current = identityPasswordPolicy; - const identityDataSource = useMemo( - () => (identityImportEnabled - ? createIdentityImportDataSource({ - base: dataSource, - authFetch: actionRuntime.authFetch, - baseUrl: import.meta.env.VITE_SERVER_URL || '', - getPasswordPolicy: () => identityPolicyRef.current, - }) - : undefined), - [identityImportEnabled, dataSource, actionRuntime.authFetch], - ); + // objectstack#25992 slice 5: identity import and the managed-environment Users + // marker moved to `./objectViewIdentity.tsx`. + const { + identityImportEnabled, + managedUsers, + managedUserMarker, + identityPasswordPolicy, + setIdentityPasswordPolicy, + identityDataSource, + } = useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshKey, t, actionRuntime }); // ─── User-defined views (metadata overlay) ────────────────────────── // Saved views created via the ViewConfigPanel ("Add View") live in the @@ -1453,41 +1397,24 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // footer record count moved to `./objectViewLiveData.ts`; the count state stays here. useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }); - // Navigation overlay for record detail (supports drawer/modal/split/popover via config) - // Priority: activeView.navigation > objectDef.navigation > default drawer. - // - // Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where - // record peek is the primary interaction and full-page is the upgrade. - // Direct URL access (`/record/:id`) still opens as a full page because - // RecordDetailView owns its own route — only same-page click navigation - // is drawer-by-default. Per-view config can still override (e.g. a heavy - // detail object can set `navigation.mode = 'page'`). - const detailNavigation: ViewNavigationConfig = useMemo( - () => { - const authored = activeView?.navigation ?? objectDef.navigation; - if (authored) { - // Authored config wins. For an overlay, resolve the `size` - // bucket (or 'auto') to a viewport-clamped width when no explicit - // width was given — objectstack-ai/objectstack#2578: a pixel width can't be authored blind. - if (authored.mode === 'page') return authored; - return { - ...authored, - width: authored.width ?? overlayWidthFor( - (authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size, - objectDef, - ), - }; - } - // objectstack-ai/objectstack#2578: derive surface + width from FIELD COUNT. Field-heavy → full - // page (cramped in a drawer); light → a drawer sized to the content - // and clamped to the viewport. Mobile always pages (in deriveRecordSurface). - return deriveRecordSurface(objectDef) === 'page' - ? { mode: 'page' } - : { mode: 'drawer', width: overlayWidthFor('auto', objectDef) }; - }, - [activeView?.navigation, objectDef] - ); - const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM); + // objectstack#25992 slice 6: the record navigation overlay (drawer state in the + // URL, row click, expand to page, record links) moved to `./objectViewRecordNavigation.ts`. + const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({ + activeView, + objectDef, + objects, + appName, + viewId, + searchParams, + setSearchParams, + location, + navigate, + routerRootHref, + servedViews, + viewLabel, + unlabelledViewText, + language, + }); /** * URL-derived equality filters in the form `?filter[]=`. @@ -1560,130 +1487,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co }, [setSearchParams], ); - // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick - const handleNavOverlayNavigate = useCallback( - (recordId: string | number, action?: string) => { - if (action === 'new_window') { - // Open record detail in a new browser tab with Console-correct URL. - // Same builder the list's link column publishes as its href - // (objectui#4490) — one record-route shape for this surface. - window.open(listRecordDetailUrl(window.location.pathname, recordId), '_blank'); - return; - } - // Default: navigate to record detail page. - // `action` is a navigation-MODE token — 'view' / 'page' / undefined - // — and every non-`new_window` value lands on the record detail - // route. - // - // ⛔ It is NOT a view name, and this comment used to say a custom - // one could arrive here "forwarded from `navigation.view` (e.g. - // 'detail_form')", resolved "by RecordDetailView from its own - // config". No layer ever resolved a view by that name — which is - // why `@objectstack/spec` 17.5.0 retired the key under ADR-0049, - // and why `useNavigationOverlay` stopped forwarding it - // (objectui#9874). Nothing here changes behaviour: this branch - // already treated the name and the token identically, which is - // exactly what made the substitution inaudible on this surface. - const originState = { - from: { - pathname: location.pathname + (location.search || ''), - label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - }, - }; - if (viewId) { - navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState }); - } else { - navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); - } - }, - [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language] - ); - /** - * The list surface's half of the record-link mechanism (objectui#4490): - * publish this page's record-URL builder so the grid's `link: true` column - * can render a real anchor instead of a click-only `span role="link"`. - * See {@link listRecordActionsValue} for what this does and does NOT claim. - * - * The record drawer/overlay below mounts `RecordDetailView`, which brings - * its own `RelatedRecordActionsBridge` around the whole detail body — that - * nearer provider keeps owning the detail surface, exactly as before. - */ - const listRecordActions = useMemo( - () => - listRecordActionsValue(objectDef.name, handleNavOverlayNavigate, undefined, { - appName, - objects, - navigate, - routerRootHref, - }), - [objectDef.name, handleNavOverlayNavigate, appName, objects, navigate, routerRootHref], - ); - const navOverlay = useNavigationOverlay({ - navigation: detailNavigation, - objectName: objectDef.name, - onNavigate: handleNavOverlayNavigate, - }); - const handleDrawerClose = () => { - navOverlay.close(); - const newParams = new URLSearchParams(searchParams); - newParams.delete(RECORD_DRAWER_PARAM); - setSearchParams(newParams); - }; - /** - * Row-click handler used by all list/grid/gallery/kanban surfaces inside - * this object view. Wraps `navOverlay.handleClick` so that drawer-mode - * opens are URL-driven (writes `?recordId=…`) — making the drawer state - * shareable, refresh-safe, and respected by browser back/forward. The - * URL→state sync effect below handles actually opening the drawer. - */ - const handleRowClick = useCallback( - (record: any, event?: React.MouseEvent | { metaKey?: boolean; ctrlKey?: boolean; button?: number }) => { - // Cmd/Ctrl/middle-click — let the hook open in a new tab (full page) - // regardless of configured mode. Matches browser link convention. - const isModifier = !!( - event && ((event as any).metaKey || (event as any).ctrlKey || (event as any).button === 1) - ); - if (isModifier) { - navOverlay.handleClick(record, event as any); - return; - } - // Drawer mode → URL is the source of truth. Push `?recordId=…` - // and let the existing URL-sync effect open the overlay. - if (navOverlay.mode === 'drawer') { - const id = (record?.id ?? record?._id) as string | number | undefined; - if (id != null) { - const next = new URLSearchParams(searchParams); - next.set(RECORD_DRAWER_PARAM, String(id)); - setSearchParams(next); - return; - } - } - // All other modes (page / modal / split / popover / new_window / none) - // — delegate to the hook. - navOverlay.handleClick(record, event as any); - }, - [navOverlay, searchParams, setSearchParams] - ); - /** - * "Expand to full page" — invoked from the drawer header chevron. Closes - * the drawer (which clears `?recordId=…`) and router-pushes to the - * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page. - */ - const handleExpandDrawer = useCallback(() => { - const rec = navOverlay.selectedRecord as Record | null; - const id = rec && ((rec as any).id ?? (rec as any)._id); - if (id == null) return; - handleDrawerClose(); - handleNavOverlayNavigate(id as string | number); - }, [navOverlay.selectedRecord, handleNavOverlayNavigate]); - // Sync URL-based recordId to overlay state - useEffect(() => { - if (drawerRecordId && !navOverlay.isOpen) { - navOverlay.open({ id: drawerRecordId }); - } else if (!drawerRecordId && navOverlay.isOpen) { - navOverlay.close(); - } - }, [drawerRecordId]); // objectui#10046 — the last `options` bag handed to `ListView`, so an // equal-content rebuild keeps its identity (see `isSameOptionsValue`). diff --git a/ui/packages/app-shell/src/views/objectViewIdentity.tsx b/ui/packages/app-shell/src/views/objectViewIdentity.tsx new file mode 100644 index 00000000000..e87d4f09dfa --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewIdentity.tsx @@ -0,0 +1,99 @@ +/** + * Identity import and the managed-environment Users marker of the Console + * `ObjectView` (`./ObjectView.tsx`): the gate that wires the import wizard to + * the auth service's import pipeline for `sys_user`, its password-policy state + * and data source, and the per-row marker on a managed environment's Users + * list, moved out of `ObjectViewInner` verbatim (objectstack#25992, slice 5). + * Every input is passed in; the component reads the returned values. + */ + +import { useState, useRef, useMemo, useCallback } from 'react'; +import type { DataSource } from '@object-ui/types'; +import { useMetadata } from '@object-ui/react'; +import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, type IdentityPasswordPolicy } from './identityImport.js'; +import { MANAGED_USERS_OBJECT, UnauthorizedUserMarker, UserGrantLoader, isManagedEnvironment } from './managedEnvironmentUsers.js'; +import { useExpressionContext } from '../providers/ExpressionProvider.js'; +import type { ConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; + +export function useObjectViewIdentity({ objectDef, isAdmin, dataSource, refreshKey, t, actionRuntime }: { + objectDef: { name: string }; + isAdmin: boolean; + dataSource: DataSource; + refreshKey: number; + t: (key: string, opts?: Record) => string; + actionRuntime: Pick; +}) { + // ─── Identity import (framework#2782) ──────────────────────────────── + // sys_user's generic import affordance stays FALSE (a data-layer insert + // would bypass better-auth's hashing and never create a credential), but + // platform admins get the same wizard wired to the dedicated + // /api/v1/auth/admin/import-users pipeline. Gated on the deployment's + // admin surface (features.admin from /api/v1/auth/config) plus the + // caller being a workspace admin (the server re-checks, ADR-0068). + // objectui#13330 — and never on a managed environment + // (`features.cloudManaged`): there the server answers `admin/import-users` + // with 409 `RESOURCE_CONFLICT`, because people arrive from the hosting + // organisation through its identity provider. + const { features } = useExpressionContext(); + const isIdentityImport = objectDef.name === IDENTITY_IMPORT_OBJECT; + const identityImportEnabled = + isIdentityImport && features?.admin === true && isAdmin && !isManagedEnvironment(features); + + // ─── Setup → Users on a managed environment (objectui#13330) ───────── + // The line that says where people are invited, and the marker on a user + // who holds no permission set beyond the baseline. The marker reads the + // grant rows through one loader per list (`managedEnvironmentUsers.tsx`), + // rebuilt when the list refreshes, so a grant made elsewhere shows on the + // next load. The metadata store is read through a ref: its context value is + // rebuilt whenever any cached type settles (AGENTS.md #10). + const managedUsers = objectDef.name === MANAGED_USERS_OBJECT && isManagedEnvironment(features); + const metadataStore = useMetadata(); + const metadataStoreRef = useRef(metadataStore); + metadataStoreRef.current = metadataStore; + const grantLoaderRef = useRef<{ refreshKey: number; source: unknown; loader: UserGrantLoader } | null>(null); + if ( + managedUsers && + dataSource && + (grantLoaderRef.current?.refreshKey !== refreshKey || grantLoaderRef.current?.source !== dataSource) + ) { + grantLoaderRef.current = { + refreshKey, + source: dataSource, + loader: new UserGrantLoader(dataSource as any, { + ensureType: (type: string) => metadataStoreRef.current.ensureType(type), + getTypeStatus: (type: string) => metadataStoreRef.current.getTypeStatus?.(type) ?? 'ready', + }), + }; + } + const grantLoader = managedUsers ? grantLoaderRef.current?.loader : undefined; + const managedUserMarker = useCallback( + (row: Record) => { + const id = row?.id ?? row?._id; + if (!grantLoader || id === undefined || id === null || id === '') return null; + return ; + }, + [grantLoader, t], + ); + const [identityPasswordPolicy, setIdentityPasswordPolicy] = useState('auto'); + const identityPolicyRef = useRef('auto'); + identityPolicyRef.current = identityPasswordPolicy; + const identityDataSource = useMemo( + () => (identityImportEnabled + ? createIdentityImportDataSource({ + base: dataSource, + authFetch: actionRuntime.authFetch, + baseUrl: import.meta.env.VITE_SERVER_URL || '', + getPasswordPolicy: () => identityPolicyRef.current, + }) + : undefined), + [identityImportEnabled, dataSource, actionRuntime.authFetch], + ); + return { + identityImportEnabled, + managedUsers, + managedUserMarker, + identityPasswordPolicy, + setIdentityPasswordPolicy, + identityDataSource, + }; +} diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts new file mode 100644 index 00000000000..f84d116853a --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -0,0 +1,211 @@ +/** + * Record navigation of the Console `ObjectView` (`./ObjectView.tsx`): how a + * row opens its record (drawer, modal, split, popover or page), the drawer's + * `?recordId=` URL state, "expand to full page", and the record links the + * list publishes, moved out of `ObjectViewInner` verbatim (objectstack#25992, + * slice 6). Every input is passed in; the component reads the returned values. + */ + +import { useMemo, useCallback, useEffect } from 'react'; +import type { NavigateFunction, Location, SetURLSearchParams } from 'react-router-dom'; +import { deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view'; +import { useNavigationOverlay } from '@object-ui/react'; +import type { ViewNavigationConfig } from '@object-ui/types'; +import { RECORD_DRAWER_PARAM } from '../urlParams.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; + +export function useObjectViewRecordNavigation({ + activeView, + objectDef, + objects, + appName, + viewId, + searchParams, + setSearchParams, + location, + navigate, + routerRootHref, + servedViews, + viewLabel, + unlabelledViewText, + language, +}: { + activeView: any; + objectDef: any; + objects: any[]; + appName: string | undefined; + viewId: string | undefined; + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + location: Location; + navigate: NavigateFunction; + routerRootHref: string; + servedViews: ReturnType; + viewLabel: ReturnType['viewLabel']; + unlabelledViewText: string; + language: ReturnType['language']; +}) { + // Navigation overlay for record detail (supports drawer/modal/split/popover via config) + // Priority: activeView.navigation > objectDef.navigation > default drawer. + // + // Default mode = 'drawer'. Mirrors Linear / Notion / Airtable / Jira where + // record peek is the primary interaction and full-page is the upgrade. + // Direct URL access (`/record/:id`) still opens as a full page because + // RecordDetailView owns its own route — only same-page click navigation + // is drawer-by-default. Per-view config can still override (e.g. a heavy + // detail object can set `navigation.mode = 'page'`). + const detailNavigation: ViewNavigationConfig = useMemo( + () => { + const authored = activeView?.navigation ?? objectDef.navigation; + if (authored) { + // Authored config wins. For an overlay, resolve the `size` + // bucket (or 'auto') to a viewport-clamped width when no explicit + // width was given — objectstack-ai/objectstack#2578: a pixel width can't be authored blind. + if (authored.mode === 'page') return authored; + return { + ...authored, + width: authored.width ?? overlayWidthFor( + (authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size, + objectDef, + ), + }; + } + // objectstack-ai/objectstack#2578: derive surface + width from FIELD COUNT. Field-heavy → full + // page (cramped in a drawer); light → a drawer sized to the content + // and clamped to the viewport. Mobile always pages (in deriveRecordSurface). + return deriveRecordSurface(objectDef) === 'page' + ? { mode: 'page' } + : { mode: 'drawer', width: overlayWidthFor('auto', objectDef) }; + }, + [activeView?.navigation, objectDef] + ); + const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM); + // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick + const handleNavOverlayNavigate = useCallback( + (recordId: string | number, action?: string) => { + if (action === 'new_window') { + // Open record detail in a new browser tab with Console-correct URL. + // Same builder the list's link column publishes as its href + // (objectui#4490) — one record-route shape for this surface. + window.open(listRecordDetailUrl(window.location.pathname, recordId), '_blank'); + return; + } + // Default: navigate to record detail page. + // `action` is a navigation-MODE token — 'view' / 'page' / undefined + // — and every non-`new_window` value lands on the record detail + // route. + // + // ⛔ It is NOT a view name, and this comment used to say a custom + // one could arrive here "forwarded from `navigation.view` (e.g. + // 'detail_form')", resolved "by RecordDetailView from its own + // config". No layer ever resolved a view by that name — which is + // why `@objectstack/spec` 17.5.0 retired the key under ADR-0049, + // and why `useNavigationOverlay` stopped forwarding it + // (objectui#9874). Nothing here changes behaviour: this branch + // already treated the name and the token identically, which is + // exactly what made the substitution inaudible on this surface. + const originState = { + from: { + pathname: location.pathname + (location.search || ''), + label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + }, + }; + if (viewId) { + navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState }); + } else { + navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); + } + }, + [navigate, viewId, location.pathname, location.search, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language] + ); + /** + * The list surface's half of the record-link mechanism (objectui#4490): + * publish this page's record-URL builder so the grid's `link: true` column + * can render a real anchor instead of a click-only `span role="link"`. + * See {@link listRecordActionsValue} for what this does and does NOT claim. + * + * The record drawer/overlay below mounts `RecordDetailView`, which brings + * its own `RelatedRecordActionsBridge` around the whole detail body — that + * nearer provider keeps owning the detail surface, exactly as before. + */ + const listRecordActions = useMemo( + () => + listRecordActionsValue(objectDef.name, handleNavOverlayNavigate, undefined, { + appName, + objects, + navigate, + routerRootHref, + }), + [objectDef.name, handleNavOverlayNavigate, appName, objects, navigate, routerRootHref], + ); + const navOverlay = useNavigationOverlay({ + navigation: detailNavigation, + objectName: objectDef.name, + onNavigate: handleNavOverlayNavigate, + }); + const handleDrawerClose = () => { + navOverlay.close(); + const newParams = new URLSearchParams(searchParams); + newParams.delete(RECORD_DRAWER_PARAM); + setSearchParams(newParams); + }; + /** + * Row-click handler used by all list/grid/gallery/kanban surfaces inside + * this object view. Wraps `navOverlay.handleClick` so that drawer-mode + * opens are URL-driven (writes `?recordId=…`) — making the drawer state + * shareable, refresh-safe, and respected by browser back/forward. The + * URL→state sync effect below handles actually opening the drawer. + */ + const handleRowClick = useCallback( + (record: any, event?: React.MouseEvent | { metaKey?: boolean; ctrlKey?: boolean; button?: number }) => { + // Cmd/Ctrl/middle-click — let the hook open in a new tab (full page) + // regardless of configured mode. Matches browser link convention. + const isModifier = !!( + event && ((event as any).metaKey || (event as any).ctrlKey || (event as any).button === 1) + ); + if (isModifier) { + navOverlay.handleClick(record, event as any); + return; + } + // Drawer mode → URL is the source of truth. Push `?recordId=…` + // and let the existing URL-sync effect open the overlay. + if (navOverlay.mode === 'drawer') { + const id = (record?.id ?? record?._id) as string | number | undefined; + if (id != null) { + const next = new URLSearchParams(searchParams); + next.set(RECORD_DRAWER_PARAM, String(id)); + setSearchParams(next); + return; + } + } + // All other modes (page / modal / split / popover / new_window / none) + // — delegate to the hook. + navOverlay.handleClick(record, event as any); + }, + [navOverlay, searchParams, setSearchParams] + ); + /** + * "Expand to full page" — invoked from the drawer header chevron. Closes + * the drawer (which clears `?recordId=…`) and router-pushes to the + * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page. + */ + const handleExpandDrawer = useCallback(() => { + const rec = navOverlay.selectedRecord as Record | null; + const id = rec && ((rec as any).id ?? (rec as any)._id); + if (id == null) return; + handleDrawerClose(); + handleNavOverlayNavigate(id as string | number); + }, [navOverlay.selectedRecord, handleNavOverlayNavigate]); + // Sync URL-based recordId to overlay state + useEffect(() => { + if (drawerRecordId && !navOverlay.isOpen) { + navOverlay.open({ id: drawerRecordId }); + } else if (!drawerRecordId && navOverlay.isOpen) { + navOverlay.close(); + } + }, [drawerRecordId]); + return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer }; +} From c416618a6dac451eea8ab6a3d066a319602c5563 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:13:24 +0000 Subject: [PATCH 05/11] refactor(app-shell): type the record navigation hook's parameters from their sources activeView, objectDef and objects were written as `any` in the new hook's parameter type. They now name where the component gets each value: the element of buildViewTabs' result, and ConsoleObjectViewProps['objects'] and its element. No new no-explicit-any warning; the moved bodies are unchanged. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .../app-shell/src/views/objectViewRecordNavigation.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts index f84d116853a..3230be0b8a0 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -16,6 +16,8 @@ import { viewTabLabel } from './viewTabLabel.js'; import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; import type { useServedViewItems } from '../hooks/useServedViewItems.js'; import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; export function useObjectViewRecordNavigation({ activeView, @@ -33,9 +35,9 @@ export function useObjectViewRecordNavigation({ unlabelledViewText, language, }: { - activeView: any; - objectDef: any; - objects: any[]; + activeView: ReturnType[number]; + objectDef: ConsoleObjectViewProps['objects'][number]; + objects: ConsoleObjectViewProps['objects']; appName: string | undefined; viewId: string | undefined; searchParams: URLSearchParams; From d725c1b6c758712b810d3ea39b9e49b0d216bdee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:07:19 +0000 Subject: [PATCH 06/11] refactor(app-shell): move ObjectView's list toolbar affordances into a sibling hook Slice 7 of the move-only ObjectView split. The environment list's entitlements, the list actions and their localized toolbar copies, and the create and import buttons' toolbar-scope predicates move verbatim into useObjectViewToolbarAffordances in views/objectViewToolbarAffordances.ts. The resolveAffordance verdict memo stays in the component (the affordance grant census reads it there) and is passed in with the other inputs, typed from their sources. No test edits. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- ...92-objectview-toolbar-affordances-split.md | 12 + .../app-shell/src/views/ObjectView.tsx | 188 +++------------- .../src/views/objectViewToolbarAffordances.ts | 208 ++++++++++++++++++ 3 files changed, 249 insertions(+), 159 deletions(-) create mode 100644 .changeset/25992-objectview-toolbar-affordances-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewToolbarAffordances.ts diff --git a/.changeset/25992-objectview-toolbar-affordances-split.md b/.changeset/25992-objectview-toolbar-affordances-split.md new file mode 100644 index 00000000000..6899c6d7c6c --- /dev/null +++ b/.changeset/25992-objectview-toolbar-affordances-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s list toolbar affordances move into a sibling hook + +Clause-②: no +Metadata-protocol: none + +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. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index e009c29e060..fca2cb60f86 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -73,13 +73,11 @@ import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext import { ManagedByBadge } from '../components/ManagedByBadge.js'; import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; -import type { RowCrudPredicates } from '../utils/crudAffordances.js'; import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js'; import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; -import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; -import { mayReadObject } from '../utils/navObjectReadGate.js'; +import { resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; import { resolveViewId } from '../utils/resolveViewId.js'; import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js'; import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js'; @@ -87,12 +85,11 @@ import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { ActionProvider, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope } from '@object-ui/react'; +import { ActionProvider, SchemaRenderer, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js'; import { actionRendersAt } from '@object-ui/types'; -import { useEnvironmentEntitlements } from '../environment/useEnvironmentEntitlements.js'; import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js'; import { VIEW_TYPE_ICONS, @@ -125,6 +122,7 @@ import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; +import { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -500,39 +498,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co }); const { confirmHandler, toastHandler } = actionRuntime; - // Environment list (sys_environment) is entitlement- + state-aware: born - // with one production env per org, its single `create_environment` action - // means different things — "Set up your production environment", "Add - // development environment", or a locked plan-tier mark linking to the - // upgrade page (objectui#12190) — depending on org state. - // Resolved org-side here; the hook is a no-op for every other object. - const isEnvironmentList = objectDef.name === 'sys_environment'; - const environmentEntitlements = useEnvironmentEntitlements({ - enabled: isEnvironmentList, - dataSource, - authFetch: actionRuntime.authFetch, - apiBase: (import.meta as any).env?.VITE_SERVER_URL || '', - refreshKey, - }); - // objectui#12109 — this object's list actions are drawn only for a caller - // who may read the object. Every one of them is bound to it (an action's - // `objectName` is what merges it into `objectDef.actions`), and a caller - // without read gets the list's "You don't have access" under them, so a - // drawn action could only end there. The verdict is the one the sidebar - // prunes this object's navigation entry on (`mayReadObject`), so the entry - // and its list actions go together. A boolean, read each render from the - // provider's verdict, so the memo below keys on a primitive (AGENTS.md #10). - const listActionsReadable = mayReadObject(perms.can, objectDef.name); - const listActions = listActionsReadable ? objectDef.actions : undefined; - // Localized `list_toolbar` actions, shared by the generic action bar and the - // environment-aware toolbar (the action:bar renderer filters by location). - // The header reads them through `headerToolbarActions`, which drops the - // selection bar's duplicates once the active view is resolved (objectui#12462). - const localizedToolbarActions = useMemo( - () => (listActionsReadable ? objectDef.actions || [] : []).map((a: any) => localizeActionTexts(objectDef.name, a)), - [objectDef, localizeActionTexts, listActionsReadable], - ); - // Resolve which generic CRUD affordances belong in the toolbar for // this object's lifecycle bucket (`managedBy`). config tables show // New/Edit/Delete but no CSV Import; system / append-only / better-auth @@ -556,131 +521,36 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co }; }, [objectDef, perms]); + // objectstack#25992 slice 7: environment entitlements, the list actions and the + // create and import toolbar verdicts moved to `./objectViewToolbarAffordances.ts`. + const { + isEnvironmentList, + environmentEntitlements, + listActions, + localizedToolbarActions, + objectCanCreate, + createVisible, + createDisabled, + listAddRecordOpensCreate, + pageOffersCreate, + objectCanImport, + importVisible, + importDisabled, + } = useObjectViewToolbarAffordances({ + objectDef, + dataSource, + actionRuntime, + refreshKey, + perms, + localizeActionTexts, + newVerdict, + importVerdict, + }); + // Externally-triggered refreshes (e.g. global ModalForm submit, undo, redo) // reach every `refreshKey` reader through the sum declared with the counter // above (objectui#10572), not through a mirroring effect. - /** - * [#5153] The object-list toolbar's CREATE predicates — the `create` half - * of the toolbar-scope pair on THIS surface. #4646 (PR #5145) gave - * `createPredicates` a consumer on the **related list** - * (`RelatedRecordActionsBridge`); the standalone object-list page kept - * gating its "New" on the object-level verdict alone, so one - * `userActions.create` object form got two different verdicts depending on - * which surface rendered the button — honoured on a record page's related - * list, ignored here. `visibleWhen: false` (the objectui#3492 shape) did - * not hide this "New". - * - * BINDING, LAYERING and the fail-CLOSED / fail-SOFT split are the import - * half's, immediately below, verbatim — the spec types the two toolbar keys - * identically and binds them in one breath. See that docblock for the - * reasoning; the only thing that differs here is which affordance is gated. - * - * TWO RENDER POINTS, ONE VERDICT. The affordance surfaces twice on this - * page — the PageHeader button (desktop) and the phone-only floating "+" - * that stands in for it once the header is hidden. They are one affordance - * rendered twice, so both consume these SAME two values; computing the - * predicate once here is what keeps them from disagreeing with each other. - */ - const objectCanCreate = newVerdict.allowed; - const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates; - /** `visibleWhen` — fails CLOSED, declared-ness by `?? true`. As Import. */ - const createVisible = useRowPredicate(createPredicates?.visibleWhen ?? true, null, { - fallback: false, - warnOnError: true, - label: 'builtin:create:visibleWhen', - }); - /** `disabledWhen` — fails SOFT, `!= null` declared-ness OUTSIDE the eval. */ - const createDisabledPred = useRowPredicate(createPredicates?.disabledWhen, null, { - fallback: false, - warnOnError: true, - label: 'builtin:create:disabledWhen', - }); - const createDisabled = createPredicates?.disabledWhen != null && createDisabledPred; - - /** - * objectui#11687 — does this page offer a way to add a row? The New button - * above, or a `list_toolbar` action the schema-driven toolbar draws (Invite - * User, Register OAuth Application, …), each by the verdict its own control - * renders on. The managed-by empty state reads it: a `better-auth` list - * must not say rows are "not added by hand here" beside such a button. - * A boolean, computed each render from the payload (`objectDef.actions`), - * so `renderListView` depends on a primitive, not a memoised identity - * (AGENTS.md #10). - */ - /** - * objectui#13089 — the view's `addRecord` Add button opens this page's own - * create (`actions.create`, the header New's), so it is offered exactly - * where that New is offered and live: the object-level verdict, the - * toolbar-scope `visibleWhen`, and not greyed by `disabledWhen` (`ListView` - * draws no greyed Add button). A boolean, so `renderListView` depends on - * a primitive (AGENTS.md #10). - */ - const listAddRecordOpensCreate = objectCanCreate && createVisible && !createDisabled; - const mayInvokeToolbarAction = useCapabilityGate(); - const toolbarPredicateScope = usePredicateScope(); - const pageOffersCreate = - (objectCanCreate && createVisible) || - listToolbarDrawsAction(listActions, mayInvokeToolbarAction, toolbarPredicateScope); - - /** - * [#5142] The object-list toolbar's IMPORT predicates — the `import` half - * of the toolbar-scope pair the spec resolver emits, mirroring what - * `RelatedRecordActionsBridge` does for `create` (objectui#4646). - * - * `@objectstack/spec@17.0.0` types `userActions.create` and - * `userActions.import` identically and `resolveCrudAffordances` emits a - * predicate envelope for each; the docblock binds them in one breath - * ("`importPredicates` — same binding as `createPredicates`"). #4646 gave - * `create` a consumer and left `import` declared-and-inert: an author could - * write `userActions.import.visibleWhen`, have the spec accept it and the - * resolver parse it, and watch this toolbar offer the CSV wizard anyway. - * - * BINDING (the spec docblock's, not an invention here). Unlike the ROW - * predicates, a toolbar predicate evaluates ONCE per toolbar against the - * record of the scope the toolbar sits in — the host parent record on a - * related list, and **no record at all on a standalone object list**, which - * is what this surface is. So `null` is passed deliberately below: a - * predicate reading `record.*` has nothing to bind, faults, and — per the - * fail-CLOSED rule — hides the button, exactly as the spec spells out. - * Predicates over the host scope (`os.user.*` / `features.*`) bind normally - * and are the meaningful shape here. - * - * LAYERING: surfaced only when the object-level verdict already passed — - * the same posture the related-list bridge takes, because a predicate may - * not RE-OPEN what the bucket, the effective API operations (objectstack#3391) or the - * principal's grant have closed. The identity-import bypass below is a - * different affordance entirely (it does not read the `listImport` row) and - * is deliberately left outside this layer. - */ - const objectCanImport = importVerdict.allowed; - const importPredicates: RowCrudPredicates | undefined = importVerdict.predicates; - /** - * `visibleWhen` — fails CLOSED, and counts as DECLARED by `?? true` rather - * than by truthiness, so `visibleWhen: false` hides Import instead of - * reading as "ungated" (the objectui#3492 invariant). The `true` default is - * a boolean, which the evaluator short-circuits without touching the engine - * — an object with no import predicates pays no evaluation at all. - */ - const importVisible = useRowPredicate(importPredicates?.visibleWhen ?? true, null, { - fallback: false, - warnOnError: true, - label: 'builtin:import:visibleWhen', - }); - /** - * `disabledWhen` — fails SOFT (an unevaluable predicate must not grey a - * button forever), with the `!= null` declared-ness gate OUTSIDE the - * evaluation so `disabledWhen: ''` reads as "no condition" rather than as - * "disable". Verbatim the posture of the record header (PR #4515), the - * row kebab, and the related-list toolbar. - */ - const importDisabledPred = useRowPredicate(importPredicates?.disabledWhen, null, { - fallback: false, - warnOnError: true, - label: 'builtin:import:disabledWhen', - }); - const importDisabled = importPredicates?.disabledWhen != null && importDisabledPred; - // Import wizard open/close state — toolbar entry triggers it. const [showImport, setShowImport] = useState(false); diff --git a/ui/packages/app-shell/src/views/objectViewToolbarAffordances.ts b/ui/packages/app-shell/src/views/objectViewToolbarAffordances.ts new file mode 100644 index 00000000000..6804de0d430 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewToolbarAffordances.ts @@ -0,0 +1,208 @@ +/** + * The list toolbar's affordances on the Console `ObjectView` + * (`./ObjectView.tsx`): the environment list's entitlements, the object's list + * actions and their localized toolbar copies, and the create and import + * buttons' toolbar-scope predicates layered on the object-level verdicts, + * moved out of `ObjectViewInner` verbatim (objectstack#25992, slice 7). The + * verdicts themselves (`resolveAffordance`) stay in the component and are + * passed in with every other input; the component reads the returned values. + */ + +import { useMemo } from 'react'; +import type { resolveAffordance } from '@object-ui/core'; +import type { DataSource } from '@object-ui/types'; +import type { usePermissions } from '@object-ui/permissions'; +import { useRowPredicate, useCapabilityGate, usePredicateScope, type useActionTextLocalizer } from '@object-ui/react'; +import type { RowCrudPredicates } from '../utils/crudAffordances.js'; +import { listToolbarDrawsAction } from '../utils/managedByEmptyState.js'; +import { mayReadObject } from '../utils/navObjectReadGate.js'; +import { useEnvironmentEntitlements } from '../environment/useEnvironmentEntitlements.js'; +import type { ConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewToolbarAffordances({ + objectDef, + dataSource, + actionRuntime, + refreshKey, + perms, + localizeActionTexts, + newVerdict, + importVerdict, +}: { + objectDef: ConsoleObjectViewProps['objects'][number]; + dataSource: DataSource; + actionRuntime: Pick; + refreshKey: number; + perms: ReturnType; + localizeActionTexts: ReturnType; + newVerdict: ReturnType; + importVerdict: ReturnType; +}) { + // Environment list (sys_environment) is entitlement- + state-aware: born + // with one production env per org, its single `create_environment` action + // means different things — "Set up your production environment", "Add + // development environment", or a locked plan-tier mark linking to the + // upgrade page (objectui#12190) — depending on org state. + // Resolved org-side here; the hook is a no-op for every other object. + const isEnvironmentList = objectDef.name === 'sys_environment'; + const environmentEntitlements = useEnvironmentEntitlements({ + enabled: isEnvironmentList, + dataSource, + authFetch: actionRuntime.authFetch, + apiBase: (import.meta as any).env?.VITE_SERVER_URL || '', + refreshKey, + }); + // objectui#12109 — this object's list actions are drawn only for a caller + // who may read the object. Every one of them is bound to it (an action's + // `objectName` is what merges it into `objectDef.actions`), and a caller + // without read gets the list's "You don't have access" under them, so a + // drawn action could only end there. The verdict is the one the sidebar + // prunes this object's navigation entry on (`mayReadObject`), so the entry + // and its list actions go together. A boolean, read each render from the + // provider's verdict, so the memo below keys on a primitive (AGENTS.md #10). + const listActionsReadable = mayReadObject(perms.can, objectDef.name); + const listActions = listActionsReadable ? objectDef.actions : undefined; + // Localized `list_toolbar` actions, shared by the generic action bar and the + // environment-aware toolbar (the action:bar renderer filters by location). + // The header reads them through `headerToolbarActions`, which drops the + // selection bar's duplicates once the active view is resolved (objectui#12462). + const localizedToolbarActions = useMemo( + () => (listActionsReadable ? objectDef.actions || [] : []).map((a: any) => localizeActionTexts(objectDef.name, a)), + [objectDef, localizeActionTexts, listActionsReadable], + ); + /** + * [#5153] The object-list toolbar's CREATE predicates — the `create` half + * of the toolbar-scope pair on THIS surface. #4646 (PR #5145) gave + * `createPredicates` a consumer on the **related list** + * (`RelatedRecordActionsBridge`); the standalone object-list page kept + * gating its "New" on the object-level verdict alone, so one + * `userActions.create` object form got two different verdicts depending on + * which surface rendered the button — honoured on a record page's related + * list, ignored here. `visibleWhen: false` (the objectui#3492 shape) did + * not hide this "New". + * + * BINDING, LAYERING and the fail-CLOSED / fail-SOFT split are the import + * half's, immediately below, verbatim — the spec types the two toolbar keys + * identically and binds them in one breath. See that docblock for the + * reasoning; the only thing that differs here is which affordance is gated. + * + * TWO RENDER POINTS, ONE VERDICT. The affordance surfaces twice on this + * page — the PageHeader button (desktop) and the phone-only floating "+" + * that stands in for it once the header is hidden. They are one affordance + * rendered twice, so both consume these SAME two values; computing the + * predicate once here is what keeps them from disagreeing with each other. + */ + const objectCanCreate = newVerdict.allowed; + const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates; + /** `visibleWhen` — fails CLOSED, declared-ness by `?? true`. As Import. */ + const createVisible = useRowPredicate(createPredicates?.visibleWhen ?? true, null, { + fallback: false, + warnOnError: true, + label: 'builtin:create:visibleWhen', + }); + /** `disabledWhen` — fails SOFT, `!= null` declared-ness OUTSIDE the eval. */ + const createDisabledPred = useRowPredicate(createPredicates?.disabledWhen, null, { + fallback: false, + warnOnError: true, + label: 'builtin:create:disabledWhen', + }); + const createDisabled = createPredicates?.disabledWhen != null && createDisabledPred; + + /** + * objectui#11687 — does this page offer a way to add a row? The New button + * above, or a `list_toolbar` action the schema-driven toolbar draws (Invite + * User, Register OAuth Application, …), each by the verdict its own control + * renders on. The managed-by empty state reads it: a `better-auth` list + * must not say rows are "not added by hand here" beside such a button. + * A boolean, computed each render from the payload (`objectDef.actions`), + * so `renderListView` depends on a primitive, not a memoised identity + * (AGENTS.md #10). + */ + /** + * objectui#13089 — the view's `addRecord` Add button opens this page's own + * create (`actions.create`, the header New's), so it is offered exactly + * where that New is offered and live: the object-level verdict, the + * toolbar-scope `visibleWhen`, and not greyed by `disabledWhen` (`ListView` + * draws no greyed Add button). A boolean, so `renderListView` depends on + * a primitive (AGENTS.md #10). + */ + const listAddRecordOpensCreate = objectCanCreate && createVisible && !createDisabled; + const mayInvokeToolbarAction = useCapabilityGate(); + const toolbarPredicateScope = usePredicateScope(); + const pageOffersCreate = + (objectCanCreate && createVisible) || + listToolbarDrawsAction(listActions, mayInvokeToolbarAction, toolbarPredicateScope); + + /** + * [#5142] The object-list toolbar's IMPORT predicates — the `import` half + * of the toolbar-scope pair the spec resolver emits, mirroring what + * `RelatedRecordActionsBridge` does for `create` (objectui#4646). + * + * `@objectstack/spec@17.0.0` types `userActions.create` and + * `userActions.import` identically and `resolveCrudAffordances` emits a + * predicate envelope for each; the docblock binds them in one breath + * ("`importPredicates` — same binding as `createPredicates`"). #4646 gave + * `create` a consumer and left `import` declared-and-inert: an author could + * write `userActions.import.visibleWhen`, have the spec accept it and the + * resolver parse it, and watch this toolbar offer the CSV wizard anyway. + * + * BINDING (the spec docblock's, not an invention here). Unlike the ROW + * predicates, a toolbar predicate evaluates ONCE per toolbar against the + * record of the scope the toolbar sits in — the host parent record on a + * related list, and **no record at all on a standalone object list**, which + * is what this surface is. So `null` is passed deliberately below: a + * predicate reading `record.*` has nothing to bind, faults, and — per the + * fail-CLOSED rule — hides the button, exactly as the spec spells out. + * Predicates over the host scope (`os.user.*` / `features.*`) bind normally + * and are the meaningful shape here. + * + * LAYERING: surfaced only when the object-level verdict already passed — + * the same posture the related-list bridge takes, because a predicate may + * not RE-OPEN what the bucket, the effective API operations (objectstack#3391) or the + * principal's grant have closed. The identity-import bypass below is a + * different affordance entirely (it does not read the `listImport` row) and + * is deliberately left outside this layer. + */ + const objectCanImport = importVerdict.allowed; + const importPredicates: RowCrudPredicates | undefined = importVerdict.predicates; + /** + * `visibleWhen` — fails CLOSED, and counts as DECLARED by `?? true` rather + * than by truthiness, so `visibleWhen: false` hides Import instead of + * reading as "ungated" (the objectui#3492 invariant). The `true` default is + * a boolean, which the evaluator short-circuits without touching the engine + * — an object with no import predicates pays no evaluation at all. + */ + const importVisible = useRowPredicate(importPredicates?.visibleWhen ?? true, null, { + fallback: false, + warnOnError: true, + label: 'builtin:import:visibleWhen', + }); + /** + * `disabledWhen` — fails SOFT (an unevaluable predicate must not grey a + * button forever), with the `!= null` declared-ness gate OUTSIDE the + * evaluation so `disabledWhen: ''` reads as "no condition" rather than as + * "disable". Verbatim the posture of the record header (PR #4515), the + * row kebab, and the related-list toolbar. + */ + const importDisabledPred = useRowPredicate(importPredicates?.disabledWhen, null, { + fallback: false, + warnOnError: true, + label: 'builtin:import:disabledWhen', + }); + const importDisabled = importPredicates?.disabledWhen != null && importDisabledPred; + return { + isEnvironmentList, + environmentEntitlements, + listActions, + localizedToolbarActions, + objectCanCreate, + createVisible, + createDisabled, + listAddRecordOpensCreate, + pageOffersCreate, + objectCanImport, + importVisible, + importDisabled, + }; +} From 0871a48a71d1917375446cc37740592d49ee6206 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:31:30 +0000 Subject: [PATCH 07/11] refactor(app-shell): move ObjectView's header toolbar and list URL state into sibling hooks Slice 8 of the move-only ObjectView split. - The header's list_toolbar actions (less the record-scoped ones the selection bar carries), the nav runAction deep link and the action:bar node move verbatim into useObjectViewHeaderToolbar in views/objectViewHeaderToolbar.ts. - The list's uf_ URL state (seed, write-back, Reset view) moves into useObjectViewListUrlState, and the filter[...] equality filters with the restored user-filter selections into useObjectViewUrlFilters, both in views/objectViewListUrlState.ts. Hook parameters are typed from their sources. One test edit, path only: ObjectView.urlFilterSuffix-9196 reads objectViewListUrlState.ts, where the scanned block now lives. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .../25992-objectview-header-list-url-split.md | 12 + .../app-shell/src/views/ObjectView.tsx | 318 +++--------------- .../ObjectView.urlFilterSuffix-9196.test.ts | 2 +- .../src/views/objectViewHeaderToolbar.ts | 104 ++++++ .../src/views/objectViewListUrlState.ts | 258 ++++++++++++++ 5 files changed, 413 insertions(+), 281 deletions(-) create mode 100644 .changeset/25992-objectview-header-list-url-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewHeaderToolbar.ts create mode 100644 ui/packages/app-shell/src/views/objectViewListUrlState.ts diff --git a/.changeset/25992-objectview-header-list-url-split.md b/.changeset/25992-objectview-header-list-url-split.md new file mode 100644 index 00000000000..2d7071dd86a --- /dev/null +++ b/.changeset/25992-objectview-header-list-url-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s header toolbar and list URL state move into sibling hooks + +Clause-②: no +Metadata-protocol: none + +Three concerns move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx`. The header's `list_toolbar` actions, the nav `runAction` deep link and the `action:bar` node move into `useObjectViewHeaderToolbar` in `objectViewHeaderToolbar.ts`. The list's toolbar state in the URL (the seed, its write-back and "Reset view") moves into `useObjectViewListUrlState`, and the `filter[...]` equality filters with the restored user-filter selections into `useObjectViewUrlFilters`, both in `objectViewListUrlState.ts`. The component passes every input in and reads the returned values. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index fca2cb60f86..18cac350a24 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -11,19 +11,9 @@ import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, createElement, type ReactNode } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom'; -import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, resolveAffordance, isRecordScopedAction, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; -import { - parseUserFilterParams, - applyUserFilterParams, - parseListStateParams, - applyListStateParams, - userFilterParamsKey, - type ListStatePatch, - type ListUrlState, -} from './userFilterUrlState.js'; -import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage.js'; +import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; +import { buildListFilterKey, writeListFilterState } from './listFilterStorage.js'; import { useObjectListPreferences, type ListViewPreference } from '../context/ListPreferencesProvider.js'; -import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js'; import { formatMetadataError } from '@object-ui/data-objectstack'; const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), @@ -88,20 +78,16 @@ import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { ActionProvider, SchemaRenderer, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; -import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js'; -import { actionRendersAt } from '@object-ui/types'; import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js'; import { VIEW_TYPE_ICONS, FALLBACK_USER, substituteFilterTokens, declaredPrimaryToolbarAction, - selectFilterParams, MOBILE_FAB_LIST_CLEARANCE, } from './objectViewHelpers.js'; import { defaultListColumnsFromObject, - bulkWiredActionNamesKey, buildAddViewSpec, loadViewOverrides, buildViewTabs, @@ -123,6 +109,8 @@ import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; import { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; +import { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; +import { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectViewListUrlState.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -795,83 +783,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co ? { ...baseView, ...viewDraft } : baseView; - // objectui#12462 (ruling A-narrow) — the header does not draw a duplicate of - // the selection bar. The bar is the list toolbar in its selection mode: a - // `list_toolbar` action the ACTIVE view wires to it (`bulkActions`, or a - // `bulkActionDefs` entry) is still drawn at its declared placement, on the - // rows it acts on, so the header's permanent copy is the duplicate. Only a - // RECORD-SCOPED action leaves (`isRecordScopedAction`: it also mounts on - // rows or records). Launched from the header it runs on exactly one ticked - // row, which the bar's per-record run covers. An object-level action keeps - // its header entry, because it can run with nothing selected and the bar - // cannot. Nothing else moves: the bar reads the same view keys itself. - // - // The names are the ones `ObjectGrid` folds into its bar from the keys this - // page relays to it (see `renderListView`): every non-empty `bulkActions` - // name except `delete`, which the grid routes to its built-in bulk delete - // rather than to an object action, and the `name` of every `bulkActionDefs` - // entry the grid can render. Keyed on a string of the names, not on - // `activeView`, which is a fresh object on every render while a draft is - // open (AGENTS.md #10). - const bulkWiredNamesKey = bulkWiredActionNamesKey(activeView); - const headerToolbarActions = useMemo(() => { - const wired = new Set(JSON.parse(bulkWiredNamesKey) as string[]); - if (wired.size === 0) return localizedToolbarActions; - return localizedToolbarActions.filter( - (a: any) => !(typeof a?.name === 'string' && wired.has(a.name) && isRecordScopedAction(a)), - ); - }, [localizedToolbarActions, bulkWiredNamesKey]); - - // [#5216] The DECLARED nav `runAction` slot, consumed generically. - // An object nav entry declaring `runAction: ''` resolves (via - // `resolveHref`) to this list's href carrying `?runAction=`; landing here - // runs that action once, through the ordinary execute path. - // - // Arm only on an action THIS toolbar actually renders — `list_toolbar` is - // the slot's surface, so a name that only exists at `record_header` cannot - // be triggered from here and must not spend the one-shot intent (#4123). - // That is also why it reads `headerToolbarActions`: an action that left the - // header for the selection bar (objectui#12462) is not armed either, and the - // link stays in the URL, as for any name this header does not draw. - // `sys_environment` is excluded because `EnvironmentListToolbar` owns the - // arming there: it must additionally wait for entitlements to resolve, and - // two consumers of one param would race to strip it. - // - // [objectui#4191] …and only on an action its author has not HIDDEN here: - // the action's own declared `visible` outranks the deep link. A hidden - // candidate is neither marked `autoTrigger` nor consumed, and the refusal - // is reported — see `useOfferedNavRunAction`, which evaluates the same - // predicate `action:button` does. - const navRunAction = useOfferedNavRunAction( - headerToolbarActions, - (a: any) => actionRendersAt(a, 'list_toolbar'), - !isEnvironmentList, - ); - // Mark exactly the requested action `autoTrigger`, leaving every other - // action's identity untouched so the bar's ordering/overflow is unchanged. - const toolbarActionsWithDeepLink = useMemo( - () => - navRunAction === null - ? headerToolbarActions - : headerToolbarActions.map((a: any) => - a?.name === navRunAction ? { ...a, autoTrigger: true } : a, - ), - [headerToolbarActions, navRunAction], - ); - // The schema-driven toolbar, typed as the `action:bar` node it is - // (objectui#11355), not as the `BaseSchema` the `schema` prop accepts: a key - // `ActionBarSchema` does not declare is refused here. On mobile it collapses - // all schema-driven toolbar actions into a single overflow menu, so the - // icon-only New / Import buttons stay visible without pushing the page title - // off-screen. - const toolbarBar: ActionBarSchema = { - type: 'action:bar', - location: 'list_toolbar', - actions: toolbarActionsWithDeepLink, - size: 'sm', - variant: 'outline', - mobileMaxVisible: 0, - }; + // objectstack#25992 slice 8: the header toolbar actions, the nav `runAction` deep link + // and the `action:bar` node moved to `./objectViewHeaderToolbar.ts`. + const { headerToolbarActions, toolbarActionsWithDeepLink, toolbarBar } = useObjectViewHeaderToolbar({ + activeView, + localizedToolbarActions, + isEnvironmentList, + }); // objectui#10694 — does the active view, as AUTHORED, declare a non-empty // `columns`? Booleans, so the relay keys on values and not on the memo's // Set (AGENTS.md #10). @@ -897,122 +815,25 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // A boolean, so the relay keys on a value (AGENTS.md #10). const activeViewIsSaved = !!baseView && isSavedViewId(savedViews, baseView.id); - /** - * objectui#11860 — the list's toolbar state in the URL: the Filter panel's - * conditions, the search term, the sort and the grouping, under the `uf_` - * family `userFilterUrlState` owns (its header names the params and their - * shapes). Panels and dialogs stay out of it. - * - * The SEED — what the list opens with — is decided once per list identity - * (object + view, the same identity `renderListView` keys the list on; - * `ListView` reads its initial filters and search once, at mount): - * - * 1. The URL, when it carries any `uf_*` state this page did not write - * itself — an opened link, a reload, Back. It is then the WHOLE state: - * the per-browser cache is not consulted, so a shared link opens the - * same list for everyone who may read it. A piece the URL lacks is - * absent, not filled from storage. - * 2. Otherwise the user's own state (objectui#12418, `listPrefs`): the - * Filter panel's conditions and the sort they left this view with, and - * the search term from the per-user cache (`listFilterStorage`); the - * view's own sort when they stored none, and its own grouping. The - * seed waits until that state has been read (`listPrefs.ready`), so a - * second browser opens on it rather than on an empty cache. - * - * Then the seed is written back into the URL (replace, never a new history - * entry), so the address bar shows the list on screen: a restored filter - * is in the link a user copies, and an entry dropped on load (a field the - * object does not declare or the user cannot read, a condition the spec - * refuses) leaves the address bar too. - * - * "Did this page write it" is `ownListParamsRef`: the `uf_*` params as the - * last write by this page left them. It matters when the identity moves - * WITHOUT a navigation — the saved views arrive after the first render and - * the active view resolves to another one — where params this page wrote - * for the first view would otherwise read as a link to the second. A real - * view switch navigates to a path with no query string, so it starts from - * the cache, and the ad-hoc params never follow the user into another view. - */ - // objectui#12418 — "Reset view" opens the list again as the view declares - // it: the identity moves, so the list remounts and re-seeds. A reset is the - // one case that WANTS the list's own state thrown away (AGENTS.md #8 is - // about refreshing data, which never does). - const [listResetGeneration, setListResetGeneration] = useState(0); - const listIdentityKey = activeView?.id - ? `${objectName}-${activeView.id}${listResetGeneration ? `~${listResetGeneration}` : ''}` - : undefined; - const listSeedRef = useRef<{ key: string; source: 'url' | 'storage'; state: ListUrlState } | undefined>(undefined); - const ownListParamsRef = useRef(undefined); - if (listIdentityKey && listPrefs.ready && listSeedRef.current?.key !== listIdentityKey) { - const carried = userFilterParamsKey(searchParams); - if (carried && carried !== ownListParamsRef.current) { - // A field the object does not declare (a stale link) or one this - // user cannot read is dropped. Field-level permissions gate only - // once loaded — the same gate `ListView` puts on its columns. - const acceptField = (field: string) => - !!objectDef.fields?.[field] - && (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read')); - listSeedRef.current = { - key: listIdentityKey, - source: 'url', - state: parseListStateParams(searchParams, acceptField), - }; - } else { - const stored = readListFilterState(buildListFilterKey(user?.id, objectName, activeView.id)); - const viewPref = listPrefs.preference.perView?.[activeView.id]; - listSeedRef.current = { - key: listIdentityKey, - source: 'storage', - state: { - filters: (viewPref?.filters ?? undefined) as ListUrlState['filters'], - search: stored?.search || undefined, - sort: viewPref?.sort, - }, - }; - } - } - useEffect(() => { - const seed = listSeedRef.current; - if (!seed || seed.key !== listIdentityKey) return; - const next = applyListStateParams(searchParams, { - filters: seed.state.filters ?? null, - search: seed.state.search ?? null, - sort: seed.state.sort ?? null, - grouping: seed.state.grouping ?? null, - }); - // Only a mirror of the CACHE is this page's own write. A link's params, - // even rewritten, stay the link's: the identity can still move to the - // view the link names once the saved views arrive. - if (seed.source === 'storage') ownListParamsRef.current = userFilterParamsKey(next); - if (next.toString() !== searchParams.toString()) setSearchParams(next, { replace: true }); - // Once per list identity, by design: later URL writes are the list's - // own changes, already on screen. (`listPrefs.ready` because the seed - // is taken only once the user's own state has been read.) - // eslint-disable-next-line react-hooks/exhaustive-deps -- see above - }, [listIdentityKey, listPrefs.ready]); - /** Write the list's own change into the URL — replace, never a history entry per keystroke. */ - const writeListUrlState = useCallback( - (patch: ListStatePatch) => { - setSearchParams(prev => { - const next = applyListStateParams(prev, patch); - ownListParamsRef.current = userFilterParamsKey(next); - return next; - }, { replace: true }); - }, - [setSearchParams], - ); - - /** - * objectui#12418 — "Reset view": drop this user's sort, filters and column - * layout for the active view, and open the list again as the view declares - * it. Offered only while they have stored something for it. - */ - const activeViewHasPreference = !!(activeView?.id && listPrefs.preference.perView?.[activeView.id]); - const handleResetView = useCallback(() => { - if (!activeView?.id) return; - listPrefs.resetView(activeView.id); - setListResetGeneration(g => g + 1); - }, [activeView?.id, listPrefs.resetView]); + // objectstack#25992 slice 8: the list's toolbar state in the URL (seed, write-back, + // "Reset view") moved to `./objectViewListUrlState.ts`. + const { + listIdentityKey, + listSeedRef, + ownListParamsRef, + writeListUrlState, + activeViewHasPreference, + handleResetView, + } = useObjectViewListUrlState({ + objectName, + objectDef, + activeView, + listPrefs, + perms, + user, + searchParams, + setSearchParams, + }); /** Real-time draft field update — propagates each toggle/input change immediately */ const handleViewUpdate = useCallback((field: string, value: any) => { @@ -1286,77 +1107,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co language, }); - /** - * URL-derived equality filters in the form `?filter[]=`. - * Used by related-list "View All" buttons to scope the destination list - * to a single parent record. Emitted as ObjectQL triples (`[field, '=', value]`) - * which matches the shape consumed by the list view's data fetcher when - * merging base filters. - * - * Read through `drillUrlFilters` — the ONE module that owns this URL family - * — rather than a private regex here (objectui#9196). This route implements - * the EQUALITY arm only: an operator suffix it cannot execute - * (`?filter[amount][gte]=100`) is DROPPED, never downgraded to equality and - * never swallowed into the field name. The greedy capture this replaced did - * the last of those, emitting a condition against a field literally named - * `amount][gte` that no object declares — a silently wrong query, not an - * ignored parameter. Range operators live on the ADR-0055 `/data` surface - * (`parseUrlFilterTriples`); giving them to this route would widen an - * addressable public surface and is deliberately not done here. - */ - // Dep on the serialized `filter[...]` entries only — `uf_*` user-filter - // params also live in the URL and must not invalidate this memo (a new - // array identity here rebuilds the whole list schema and refetches). - const filterParams = selectFilterParams(searchParams); - const filterParamsKey = filterParams.toString(); - const urlFilters = useMemo( - // The params object is read DIRECTLY — nothing is serialized here and - // parsed back, so there is no round trip left for a character to be - // lost in (objectui#9287). `filterParamsKey` is the memo's identity - // only. - () => parseUrlEqualityFilterTriples(filterParams), - // Keyed on the SERIALIZED params, not on the object's identity: - // `filterParams` is rebuilt every render, so listing it would - // invalidate this memo on every unrelated `uf_*` write — the churn - // this key exists to prevent. Equal keys imply equal contents, so the - // captured object is never stale. - // eslint-disable-next-line react-hooks/exhaustive-deps -- see above - [filterParamsKey], - ); - - /** - * End-user filter selections restored from `uf_*` URL params (ADR-0047 - * persistence), read from the CURRENT location once per list identity — - * the identity `listSeedRef` seeds on and `renderListView` keys the list - * on (objectui#11992). A view switch keeps this page mounted and remounts - * the list, whose `UserFilters` read these once, at that mount. So each - * view starts from the selections its own URL carries: none on the bare - * path a view tab navigates to, the link's on a link, the entry's own on - * Back. A capture once per PAGE mount restored the first view's selections - * on every view after it, while their address bar showed none. - * - * Held per identity rather than re-read every render: the list's own - * writes (below) reach the URL after the list already shows them, and a - * new object here would rebuild the list's schema on every chip click. - * The router's location is read, not `window.location`: it is the one this - * page writes to and the one `listSeedRef` reads. - */ - const ufSelectionsRef = useRef<{ key: string; selections: Record | undefined } | undefined>(undefined); - if (listIdentityKey && ufSelectionsRef.current?.key !== listIdentityKey) { - ufSelectionsRef.current = { key: listIdentityKey, selections: parseUserFilterParams(searchParams) }; - } - const initialUfSelections = listIdentityKey ? ufSelectionsRef.current?.selections : undefined; - const handleUserFilterSelectionsChange = useCallback( - (selections: Record>) => { - setSearchParams(prev => { - const next = applyUserFilterParams(prev, selections); - // objectui#11860 — this page's own write (see `ownListParamsRef`). - ownListParamsRef.current = userFilterParamsKey(next); - return next; - }, { replace: true }); - }, - [setSearchParams], - ); + // objectstack#25992 slice 8: the `filter[...]` URL equality filters and the `uf_*` + // user-filter selections moved to `./objectViewListUrlState.ts`. + const { urlFilters, initialUfSelections, handleUserFilterSelectionsChange } = useObjectViewUrlFilters({ + searchParams, + setSearchParams, + listIdentityKey, + ownListParamsRef, + }); // objectui#10046 — the last `options` bag handed to `ListView`, so an // equal-content rebuild keeps its identity (see `isSameOptionsValue`). diff --git a/ui/packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts b/ui/packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts index fa350d72d7f..db835d2033f 100644 --- a/ui/packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.urlFilterSuffix-9196.test.ts @@ -189,7 +189,7 @@ describe('objectui#9196 — the equality arm this route DOES implement', () => { describe('objectui#9196 — `ObjectView` no longer keeps a private reader of this family', () => { const here = path.dirname(fileURLToPath(import.meta.url)); - const SOURCE = readFileSync(path.join(here, 'ObjectView.tsx'), 'utf8'); + const SOURCE = readFileSync(path.join(here, 'objectViewListUrlState.ts'), 'utf8'); /** Positive control: a wrong or empty read fails here, not silently below. */ const ANCHOR = 'const urlFilters'; diff --git a/ui/packages/app-shell/src/views/objectViewHeaderToolbar.ts b/ui/packages/app-shell/src/views/objectViewHeaderToolbar.ts new file mode 100644 index 00000000000..ebf29df0fcc --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewHeaderToolbar.ts @@ -0,0 +1,104 @@ +/** + * The page header's toolbar on the Console `ObjectView` (`./ObjectView.tsx`): + * the `list_toolbar` actions the header draws (less the record-scoped ones the + * selection bar already carries), the nav `runAction` deep link armed on them, + * and the `action:bar` node that renders them, moved out of `ObjectViewInner` + * verbatim (objectstack#25992, slice 8). Every input is passed in; the + * component reads the returned values. + */ + +import { useMemo } from 'react'; +import { isRecordScopedAction } from '@object-ui/core'; +import { actionRendersAt, type ActionBarSchema } from '@object-ui/types'; +import { useOfferedNavRunAction } from '../hooks/useNavRunAction.js'; +import { bulkWiredActionNamesKey, type buildViewTabs } from './objectViewTabs.js'; +import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; + +export function useObjectViewHeaderToolbar({ + activeView, + localizedToolbarActions, + isEnvironmentList, +}: { + activeView: ReturnType[number]; + localizedToolbarActions: ReturnType['localizedToolbarActions']; + isEnvironmentList: boolean; +}) { + // objectui#12462 (ruling A-narrow) — the header does not draw a duplicate of + // the selection bar. The bar is the list toolbar in its selection mode: a + // `list_toolbar` action the ACTIVE view wires to it (`bulkActions`, or a + // `bulkActionDefs` entry) is still drawn at its declared placement, on the + // rows it acts on, so the header's permanent copy is the duplicate. Only a + // RECORD-SCOPED action leaves (`isRecordScopedAction`: it also mounts on + // rows or records). Launched from the header it runs on exactly one ticked + // row, which the bar's per-record run covers. An object-level action keeps + // its header entry, because it can run with nothing selected and the bar + // cannot. Nothing else moves: the bar reads the same view keys itself. + // + // The names are the ones `ObjectGrid` folds into its bar from the keys this + // page relays to it (see `renderListView`): every non-empty `bulkActions` + // name except `delete`, which the grid routes to its built-in bulk delete + // rather than to an object action, and the `name` of every `bulkActionDefs` + // entry the grid can render. Keyed on a string of the names, not on + // `activeView`, which is a fresh object on every render while a draft is + // open (AGENTS.md #10). + const bulkWiredNamesKey = bulkWiredActionNamesKey(activeView); + const headerToolbarActions = useMemo(() => { + const wired = new Set(JSON.parse(bulkWiredNamesKey) as string[]); + if (wired.size === 0) return localizedToolbarActions; + return localizedToolbarActions.filter( + (a: any) => !(typeof a?.name === 'string' && wired.has(a.name) && isRecordScopedAction(a)), + ); + }, [localizedToolbarActions, bulkWiredNamesKey]); + + // [#5216] The DECLARED nav `runAction` slot, consumed generically. + // An object nav entry declaring `runAction: ''` resolves (via + // `resolveHref`) to this list's href carrying `?runAction=`; landing here + // runs that action once, through the ordinary execute path. + // + // Arm only on an action THIS toolbar actually renders — `list_toolbar` is + // the slot's surface, so a name that only exists at `record_header` cannot + // be triggered from here and must not spend the one-shot intent (#4123). + // That is also why it reads `headerToolbarActions`: an action that left the + // header for the selection bar (objectui#12462) is not armed either, and the + // link stays in the URL, as for any name this header does not draw. + // `sys_environment` is excluded because `EnvironmentListToolbar` owns the + // arming there: it must additionally wait for entitlements to resolve, and + // two consumers of one param would race to strip it. + // + // [objectui#4191] …and only on an action its author has not HIDDEN here: + // the action's own declared `visible` outranks the deep link. A hidden + // candidate is neither marked `autoTrigger` nor consumed, and the refusal + // is reported — see `useOfferedNavRunAction`, which evaluates the same + // predicate `action:button` does. + const navRunAction = useOfferedNavRunAction( + headerToolbarActions, + (a: any) => actionRendersAt(a, 'list_toolbar'), + !isEnvironmentList, + ); + // Mark exactly the requested action `autoTrigger`, leaving every other + // action's identity untouched so the bar's ordering/overflow is unchanged. + const toolbarActionsWithDeepLink = useMemo( + () => + navRunAction === null + ? headerToolbarActions + : headerToolbarActions.map((a: any) => + a?.name === navRunAction ? { ...a, autoTrigger: true } : a, + ), + [headerToolbarActions, navRunAction], + ); + // The schema-driven toolbar, typed as the `action:bar` node it is + // (objectui#11355), not as the `BaseSchema` the `schema` prop accepts: a key + // `ActionBarSchema` does not declare is refused here. On mobile it collapses + // all schema-driven toolbar actions into a single overflow menu, so the + // icon-only New / Import buttons stay visible without pushing the page title + // off-screen. + const toolbarBar: ActionBarSchema = { + type: 'action:bar', + location: 'list_toolbar', + actions: toolbarActionsWithDeepLink, + size: 'sm', + variant: 'outline', + mobileMaxVisible: 0, + }; + return { headerToolbarActions, toolbarActionsWithDeepLink, toolbarBar }; +} diff --git a/ui/packages/app-shell/src/views/objectViewListUrlState.ts b/ui/packages/app-shell/src/views/objectViewListUrlState.ts new file mode 100644 index 00000000000..227d16038ff --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewListUrlState.ts @@ -0,0 +1,258 @@ +/** + * The list's URL state on the Console `ObjectView` (`./ObjectView.tsx`): the + * Filter panel, search, sort and grouping under the `uf_` family (the seed a + * list opens with, its write-back, and "Reset view"), and the `filter[...]` + * equality filters with the end-user filter selections restored from the URL, + * moved out of `ObjectViewInner` verbatim (objectstack#25992, slice 8). Every + * input is passed in; the component reads the returned values. + */ + +import { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import type { SetURLSearchParams } from 'react-router-dom'; +import type { useAuth } from '@object-ui/auth'; +import type { usePermissions } from '@object-ui/permissions'; +import { + parseUserFilterParams, + applyUserFilterParams, + parseListStateParams, + applyListStateParams, + userFilterParamsKey, + type ListStatePatch, + type ListUrlState, +} from './userFilterUrlState.js'; +import { buildListFilterKey, readListFilterState } from './listFilterStorage.js'; +import type { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; +import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js'; +import { selectFilterParams } from './objectViewHelpers.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewListUrlState({ + objectName, + objectDef, + activeView, + listPrefs, + perms, + user, + searchParams, + setSearchParams, +}: { + objectName: string | undefined; + objectDef: ConsoleObjectViewProps['objects'][number]; + activeView: ReturnType[number]; + listPrefs: ReturnType; + perms: ReturnType; + user: ReturnType['user']; + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; +}) { + /** + * objectui#11860 — the list's toolbar state in the URL: the Filter panel's + * conditions, the search term, the sort and the grouping, under the `uf_` + * family `userFilterUrlState` owns (its header names the params and their + * shapes). Panels and dialogs stay out of it. + * + * The SEED — what the list opens with — is decided once per list identity + * (object + view, the same identity `renderListView` keys the list on; + * `ListView` reads its initial filters and search once, at mount): + * + * 1. The URL, when it carries any `uf_*` state this page did not write + * itself — an opened link, a reload, Back. It is then the WHOLE state: + * the per-browser cache is not consulted, so a shared link opens the + * same list for everyone who may read it. A piece the URL lacks is + * absent, not filled from storage. + * 2. Otherwise the user's own state (objectui#12418, `listPrefs`): the + * Filter panel's conditions and the sort they left this view with, and + * the search term from the per-user cache (`listFilterStorage`); the + * view's own sort when they stored none, and its own grouping. The + * seed waits until that state has been read (`listPrefs.ready`), so a + * second browser opens on it rather than on an empty cache. + * + * Then the seed is written back into the URL (replace, never a new history + * entry), so the address bar shows the list on screen: a restored filter + * is in the link a user copies, and an entry dropped on load (a field the + * object does not declare or the user cannot read, a condition the spec + * refuses) leaves the address bar too. + * + * "Did this page write it" is `ownListParamsRef`: the `uf_*` params as the + * last write by this page left them. It matters when the identity moves + * WITHOUT a navigation — the saved views arrive after the first render and + * the active view resolves to another one — where params this page wrote + * for the first view would otherwise read as a link to the second. A real + * view switch navigates to a path with no query string, so it starts from + * the cache, and the ad-hoc params never follow the user into another view. + */ + // objectui#12418 — "Reset view" opens the list again as the view declares + // it: the identity moves, so the list remounts and re-seeds. A reset is the + // one case that WANTS the list's own state thrown away (AGENTS.md #8 is + // about refreshing data, which never does). + const [listResetGeneration, setListResetGeneration] = useState(0); + const listIdentityKey = activeView?.id + ? `${objectName}-${activeView.id}${listResetGeneration ? `~${listResetGeneration}` : ''}` + : undefined; + const listSeedRef = useRef<{ key: string; source: 'url' | 'storage'; state: ListUrlState } | undefined>(undefined); + const ownListParamsRef = useRef(undefined); + if (listIdentityKey && listPrefs.ready && listSeedRef.current?.key !== listIdentityKey) { + const carried = userFilterParamsKey(searchParams); + if (carried && carried !== ownListParamsRef.current) { + // A field the object does not declare (a stale link) or one this + // user cannot read is dropped. Field-level permissions gate only + // once loaded — the same gate `ListView` puts on its columns. + const acceptField = (field: string) => + !!objectDef.fields?.[field] + && (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read')); + listSeedRef.current = { + key: listIdentityKey, + source: 'url', + state: parseListStateParams(searchParams, acceptField), + }; + } else { + const stored = readListFilterState(buildListFilterKey(user?.id, objectName, activeView.id)); + const viewPref = listPrefs.preference.perView?.[activeView.id]; + listSeedRef.current = { + key: listIdentityKey, + source: 'storage', + state: { + filters: (viewPref?.filters ?? undefined) as ListUrlState['filters'], + search: stored?.search || undefined, + sort: viewPref?.sort, + }, + }; + } + } + useEffect(() => { + const seed = listSeedRef.current; + if (!seed || seed.key !== listIdentityKey) return; + const next = applyListStateParams(searchParams, { + filters: seed.state.filters ?? null, + search: seed.state.search ?? null, + sort: seed.state.sort ?? null, + grouping: seed.state.grouping ?? null, + }); + // Only a mirror of the CACHE is this page's own write. A link's params, + // even rewritten, stay the link's: the identity can still move to the + // view the link names once the saved views arrive. + if (seed.source === 'storage') ownListParamsRef.current = userFilterParamsKey(next); + if (next.toString() !== searchParams.toString()) setSearchParams(next, { replace: true }); + // Once per list identity, by design: later URL writes are the list's + // own changes, already on screen. (`listPrefs.ready` because the seed + // is taken only once the user's own state has been read.) + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + }, [listIdentityKey, listPrefs.ready]); + /** Write the list's own change into the URL — replace, never a history entry per keystroke. */ + const writeListUrlState = useCallback( + (patch: ListStatePatch) => { + setSearchParams(prev => { + const next = applyListStateParams(prev, patch); + ownListParamsRef.current = userFilterParamsKey(next); + return next; + }, { replace: true }); + }, + [setSearchParams], + ); + + /** + * objectui#12418 — "Reset view": drop this user's sort, filters and column + * layout for the active view, and open the list again as the view declares + * it. Offered only while they have stored something for it. + */ + const activeViewHasPreference = !!(activeView?.id && listPrefs.preference.perView?.[activeView.id]); + const handleResetView = useCallback(() => { + if (!activeView?.id) return; + listPrefs.resetView(activeView.id); + setListResetGeneration(g => g + 1); + }, [activeView?.id, listPrefs.resetView]); + return { + listIdentityKey, + listSeedRef, + ownListParamsRef, + writeListUrlState, + activeViewHasPreference, + handleResetView, + }; +} + +export function useObjectViewUrlFilters({ + searchParams, + setSearchParams, + listIdentityKey, + ownListParamsRef, +}: { + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + listIdentityKey: ReturnType['listIdentityKey']; + ownListParamsRef: ReturnType['ownListParamsRef']; +}) { + /** + * URL-derived equality filters in the form `?filter[]=`. + * Used by related-list "View All" buttons to scope the destination list + * to a single parent record. Emitted as ObjectQL triples (`[field, '=', value]`) + * which matches the shape consumed by the list view's data fetcher when + * merging base filters. + * + * Read through `drillUrlFilters` — the ONE module that owns this URL family + * — rather than a private regex here (objectui#9196). This route implements + * the EQUALITY arm only: an operator suffix it cannot execute + * (`?filter[amount][gte]=100`) is DROPPED, never downgraded to equality and + * never swallowed into the field name. The greedy capture this replaced did + * the last of those, emitting a condition against a field literally named + * `amount][gte` that no object declares — a silently wrong query, not an + * ignored parameter. Range operators live on the ADR-0055 `/data` surface + * (`parseUrlFilterTriples`); giving them to this route would widen an + * addressable public surface and is deliberately not done here. + */ + // Dep on the serialized `filter[...]` entries only — `uf_*` user-filter + // params also live in the URL and must not invalidate this memo (a new + // array identity here rebuilds the whole list schema and refetches). + const filterParams = selectFilterParams(searchParams); + const filterParamsKey = filterParams.toString(); + const urlFilters = useMemo( + // The params object is read DIRECTLY — nothing is serialized here and + // parsed back, so there is no round trip left for a character to be + // lost in (objectui#9287). `filterParamsKey` is the memo's identity + // only. + () => parseUrlEqualityFilterTriples(filterParams), + // Keyed on the SERIALIZED params, not on the object's identity: + // `filterParams` is rebuilt every render, so listing it would + // invalidate this memo on every unrelated `uf_*` write — the churn + // this key exists to prevent. Equal keys imply equal contents, so the + // captured object is never stale. + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + [filterParamsKey], + ); + + /** + * End-user filter selections restored from `uf_*` URL params (ADR-0047 + * persistence), read from the CURRENT location once per list identity — + * the identity `listSeedRef` seeds on and `renderListView` keys the list + * on (objectui#11992). A view switch keeps this page mounted and remounts + * the list, whose `UserFilters` read these once, at that mount. So each + * view starts from the selections its own URL carries: none on the bare + * path a view tab navigates to, the link's on a link, the entry's own on + * Back. A capture once per PAGE mount restored the first view's selections + * on every view after it, while their address bar showed none. + * + * Held per identity rather than re-read every render: the list's own + * writes (below) reach the URL after the list already shows them, and a + * new object here would rebuild the list's schema on every chip click. + * The router's location is read, not `window.location`: it is the one this + * page writes to and the one `listSeedRef` reads. + */ + const ufSelectionsRef = useRef<{ key: string; selections: Record | undefined } | undefined>(undefined); + if (listIdentityKey && ufSelectionsRef.current?.key !== listIdentityKey) { + ufSelectionsRef.current = { key: listIdentityKey, selections: parseUserFilterParams(searchParams) }; + } + const initialUfSelections = listIdentityKey ? ufSelectionsRef.current?.selections : undefined; + const handleUserFilterSelectionsChange = useCallback( + (selections: Record>) => { + setSearchParams(prev => { + const next = applyUserFilterParams(prev, selections); + // objectui#11860 — this page's own write (see `ownListParamsRef`). + ownListParamsRef.current = userFilterParamsKey(next); + return next; + }, { replace: true }); + }, + [setSearchParams], + ); + return { urlFilters, initialUfSelections, handleUserFilterSelectionsChange }; +} From cd7c792524e7de5cbee26270b6e59850afaf7ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:57:29 +0000 Subject: [PATCH 08/11] refactor(app-shell): move ObjectView's view tab strip, plugin schema and header chrome into sibling hooks Move-only split of ObjectViewInner: the view tab items, ViewTabBar and its own-row face go to objectViewTabBar.tsx; the merged views and the plugin ObjectViewSchema go to objectViewPluginSchema.ts; the create button, folded header actions menu and refused-view face go to objectViewHeaderChrome.tsx. Each hook is called where its block stood. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- ...-objectview-tab-bar-header-chrome-split.md | 12 + .../app-shell/src/views/ObjectView.tsx | 334 ++++-------------- .../src/views/objectViewHeaderChrome.tsx | 176 +++++++++ .../src/views/objectViewPluginSchema.ts | 111 ++++++ .../app-shell/src/views/objectViewTabBar.tsx | 142 ++++++++ 5 files changed, 518 insertions(+), 257 deletions(-) create mode 100644 .changeset/25992-objectview-tab-bar-header-chrome-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx create mode 100644 ui/packages/app-shell/src/views/objectViewPluginSchema.ts create mode 100644 ui/packages/app-shell/src/views/objectViewTabBar.tsx diff --git a/.changeset/25992-objectview-tab-bar-header-chrome-split.md b/.changeset/25992-objectview-tab-bar-header-chrome-split.md new file mode 100644 index 00000000000..b63023ce18a --- /dev/null +++ b/.changeset/25992-objectview-tab-bar-header-chrome-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s view tab strip, plugin schema and header chrome move into sibling hooks + +Clause-②: no +Metadata-protocol: none + +Three concerns move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx`. The view tab items, the `ViewTabBar` handed to the list toolbar and its own-row face move into `useObjectViewTabBar` in `objectViewTabBar.tsx`. The views with the open draft merged in and the `ObjectViewSchema` handed to the plugin `ObjectView` move into `useObjectViewPluginSchema` in `objectViewPluginSchema.ts`. The header's create button, its folded actions menu and the list area for a refused view move into `useObjectViewHeaderChrome` in `objectViewHeaderChrome.tsx`. The component passes every input in and reads the returned values. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 18cac350a24..657bf3eea4a 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -22,8 +22,7 @@ const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })), ); import { ListView } from '@object-ui/plugin-list'; -import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view'; -import type { ViewTabItem } from '@object-ui/plugin-view'; +import { ObjectView as PluginObjectView, ManageViewsDialog } from '@object-ui/plugin-view'; // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here. @@ -33,19 +32,16 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Empty, - EmptyTitle, - EmptyDescription, NavigationOverlay, } from '@object-ui/components'; -import { Plus, Upload, Lock, MoreHorizontal, RotateCcw } from 'lucide-react'; +import { Plus, Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; import { FavoriteMenuItem, useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; import { viewTabLabel } from './viewTabLabel.js'; import { getIcon } from '../utils/getIcon.js'; -import type { ActionBarSchema, DataSource, UIActionSchema, ListViewSchema, TreeViewConfig } from '@object-ui/types'; +import type { DataSource, ListViewSchema, TreeViewConfig } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; @@ -72,7 +68,7 @@ import { resolveViewId } from '../utils/resolveViewId.js'; import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js'; import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js'; import { useObjectActions } from '../hooks/useObjectActions.js'; -import { useObjectTranslation, useObjectLabel, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; +import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { ActionProvider, SchemaRenderer, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; @@ -83,8 +79,6 @@ import { VIEW_TYPE_ICONS, FALLBACK_USER, substituteFilterTokens, - declaredPrimaryToolbarAction, - MOBILE_FAB_LIST_CLEARANCE, } from './objectViewHelpers.js'; import { defaultListColumnsFromObject, @@ -104,13 +98,16 @@ import { isSameOptionsValue, dispatchViewPatches, } from './objectViewOptions.js'; -import { ObjectNotFoundState, useViewTabsFit, keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import { ObjectNotFoundState, keepInlineEditModeForTheSession } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; import { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; import { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectViewListUrlState.js'; +import { useObjectViewTabBar } from './objectViewTabBar.js'; +import { useObjectViewPluginSchema } from './objectViewPluginSchema.js'; +import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -1128,82 +1125,29 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // effect, so each host re-render re-issued the identical list query. const heldListFilter = useRef(undefined); - /* - * View Tabs — Airtable-style switcher with rename / delete / duplicate / - * pin / set-default / drag-reorder. Built-in views (sourced from - * objectDef.listViews) only support switching; mutating callbacks - * short-circuit with a toast. - * - * objectui#12440 — the strip is no longer a row of its own above the list: - * it is handed to `ListView` as its `toolbarLeading` host prop, so the tabs - * and the list's Filter · Sort · Display · Search share one row. From - * `sm` up only; below it nothing is handed over and the app bar's mobile - * view switcher (registered above) stays the phone's view control. - */ - const viewTabsFit = useViewTabsFit(); - // objectui#12528 — the view's description rides on its tab (the active - // tab's tooltip and ▾ menu header) instead of a row above the toolbar. - // Resolved the way `ListView` resolves the same text (`pickLocalized` on - // the display locale), and gated on the same switch the relay below hands - // `ListView` as `appearance.showDescription`: `false` shows it nowhere. - const displayLocale = useDisplayLocale(); - const viewTabItems: ViewTabItem[] = views.map((view: any) => { - const saved = savedViews.find((sv: any) => viewRowId(sv) === view.id); - // System views (loaded from objectDef.listViews / metadata) are - // *read-only*. Only sys_view-backed records can be mutated by - // the user; admins must duplicate a system view to customize it. - const isSystem = !saved; - return { - id: view.id, - label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - type: view.type, - hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0, - hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0, - isDefault: !!(saved?.isDefault ?? view.isDefault), - isPinned: !!(saved?.isPinned ?? view.isPinned), - visibility: saved?.visibility ?? view.visibility, - readonly: isSystem, - readonlyReason: isSystem - ? t('console.objectView.systemViewReadonly') - : undefined, - description: (view.showDescription ?? view.appearance?.showDescription) === false - ? undefined - : pickLocalized(view.description, displayLocale) || undefined, - } as ViewTabItem; + // objectstack#25992 slice 9: the view tab items, the `ViewTabBar` strip and its + // own-row face moved to `./objectViewTabBar.tsx`. + const { viewTabItems, viewTabBar, viewTabRow } = useObjectViewTabBar({ + views, + savedViews, + objectDef, + servedViews, + viewLabel, + unlabelledViewText, + language, + t, + viewAccess, + activeViewId, + handleViewChange, + isAdmin, + handleAddView, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleConfigView, + setManageViewsOpen, }); - const viewTabBar = viewTabsFit && views.length >= 1 ? ( - setManageViewsOpen(true) : undefined} - /> - ) : null; - - // objectui#13114 — the strip as a row of its own, for the faces that have - // no `ListView` toolbar to carry it: a view the server refused (drawn - // page-level below) and a `type: 'chart'` view (drawn above the chart in - // `renderListView`'s chart branch, which returns before `ListView`). Every - // other face gets the strip in the list's toolbar row instead, so no face - // ever draws two. Gated on `viewTabBar`, so below `sm` it is absent too. - const viewTabRow = viewTabBar ? ( -
- {viewTabBar} -
- ) : null; // Render multi-view content via ListView plugin (for kanban, calendar, etc.) const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className, refreshKey: pluginRefreshKey }: any) => { @@ -1972,178 +1916,54 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co ); }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker]); - // Memoize the merged views array so PluginObjectView doesn't get a new - // reference on every render (which would trigger unnecessary data refetches). - const mergedViews = useMemo(() => - views.map((v: any) => - v.id === activeViewId && viewDraft && viewDraft.id === v.id - ? { ...v, ...viewDraft } - : v - ), - [views, activeViewId, viewDraft] - ); + // objectstack#25992 slice 9: the merged views and the plugin's `ObjectViewSchema` + // moved to `./objectViewPluginSchema.ts`. + const { mergedViews, objectViewSchema } = useObjectViewPluginSchema({ + views, + activeViewId, + viewDraft, + activeView, + objectDef, + onEdit, + isAdmin, + location, + navigate, + viewId, + servedViews, + viewLabel, + unlabelledViewText, + language, + }); - // objectui#11013 — the active view's toolbar policy, read under its declared - // spelling `userActions.search` / `.filter` / `.sort`. These used to be read - // as the bare `showSearch` / `showFilters` / `showSort` flags, so a view - // that declared `userActions: { search: false }` still got a search box - // here. A stored view that still carries a bare flag is folded onto - // `userActions` by `normalizeListViewSchema`, the one fold the relay's own - // `userActions` rung already runs; this read names no legacy key. Not - // memoised: `activeView` is a fresh object on every render while a draft is - // open, and the schema below keys on the three booleans, not on this value. - const activeViewUserActions = (normalizeListViewSchema(activeView ?? {}) as { - userActions?: { search?: boolean; filter?: boolean; sort?: boolean }; - }).userActions; - const activeViewSearch = activeViewUserActions?.search !== false; - const activeViewFilter = activeViewUserActions?.filter !== false; - const activeViewSort = activeViewUserActions?.sort !== false; - - // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) - const objectViewSchema = useMemo(() => ({ - type: 'object-view' as const, - objectName: objectDef.name, - layout: 'page' as const, - showSearch: activeViewSearch, - showFilters: activeViewFilter, - showSort: activeViewSort, - showCreate: false, // We render our own create button in the header - allowCreateView: isAdmin, - viewActions: isAdmin ? [ - { type: 'settings' as const }, - { type: 'share' as const }, - { type: 'delete' as const }, - ] : [], - onNavigate: (recordId: string | number, mode: 'view' | 'edit') => { - if (mode === 'edit') { - onEdit?.({ id: recordId }); - } else if (mode === 'view') { - const originState = { - from: { - pathname: location.pathname + (location.search || ''), - label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - }, - }; - if (viewId) { - navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState }); - } else { - navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); - } - } - }, - }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews, language]); - - // The header's primary action, drawn the same in both of the header's - // faces (below). [#5153] `objectCanCreate && createVisible` — the - // object-level verdict, then the toolbar-scope `visibleWhen` layer on top - // of it. Greyed, not gone, is the `disabledWhen` case. Same pair drives the - // phone FAB below, so the two render points cannot disagree. - const showCreate = objectCanCreate && createVisible; - // objectui#13134 — the list's end-of-scroll room for the phone FAB, on the - // list host below, exactly while the FAB is drawn (see - // `MOBILE_FAB_LIST_CLEARANCE`). - const listFabClearance = showCreate ? MOBILE_FAB_LIST_CLEARANCE : undefined; - const createButton = showCreate ? ( - - ) : null; - - // objectui#12705 — the header's actions folded, for a header too narrow - // for them and the description: the primary action stays labelled and - // every other action goes into one `⋯` menu, above the page's own entries - // (favorites, Reset view). The header decides when; this decides what. - // - // The primary is DECLARED, never read off the row's order: the page's own - // New when it draws one, else the toolbar action authored - // `variant: 'primary'` (lowest `order` first) — `sys_user`'s Invite User. - // Everything else goes through one `action:bar` with no inline slots, so - // the menu is that bar's one overflow menu: the toolbar actions keep the - // bar's capability, location and `visible` / `disabled` gates and its - // execute path, as when they spill past `maxVisible` today. Import and the - // page's own entries join it as host callbacks, the way the record page's - // menu carries Favorite and Share. - // - // A `?runAction=` deep link still runs once: the arrival's render consumes - // it before the header can fold, so no action in the folded face carries - // `autoTrigger`. Not offered on the environment list, whose toolbar is - // `EnvironmentListToolbar`, not this bar. - const toolbarRowActions: UIActionSchema[] = listActions?.some((a: { locations?: readonly string[] }) => a.locations?.includes('list_toolbar')) - ? toolbarActionsWithDeepLink - : []; - const primaryToolbarAction = showCreate ? undefined : declaredPrimaryToolbarAction(toolbarRowActions); - const importShown = identityImportEnabled || (objectCanImport && importVisible); - const foldedMenuBar: ActionBarSchema = { - type: 'action:bar', - location: 'list_toolbar', - actions: [ - ...(importShown ? [{ - name: 'object_view_import', - label: t('console.objectView.import'), - icon: 'upload', - type: 'script' as const, - locations: ['list_toolbar' as const], - disabled: importDisabled, - onClick: () => setShowImport(true), - }] : []), - ...toolbarRowActions.filter((a) => a.name !== primaryToolbarAction?.name), - ], - systemActions: [ - ...(listFavorite ? [{ - name: 'object_view_favorite', - label: listFavorite.isFavorite - ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) - : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), - icon: listFavorite.isFavorite ? 'star-off' : 'star', - type: 'script' as const, - onClick: listFavorite.toggle, - }] : []), - ...(activeViewHasPreference ? [{ - name: 'object_view_reset_view', - label: t('console.objectView.resetView'), - icon: 'rotate-ccw', - type: 'script' as const, - onClick: handleResetView, - }] : []), - ], - variant: 'ghost', - size: 'icon', - maxVisible: 0, - mobileMaxVisible: 0, - }; - const foldedHeaderActions = !isEnvironmentList ? ( - <> - {createButton} - {/* One inline slot at every width: `toolbarBar` keeps none below - `md` (`mobileMaxVisible: 0`), which would send the primary into - a second `⋯` beside the menu's. */} - {primaryToolbarAction && ( - - )} - - - ) : undefined; - - // objectui#12112 — the list area for a view the server refused: said as a - // permission refusal, in the server's own sentence, which names the view. - const refusedViewFace = viewAccess.refusal ? ( -
- -
- -
- {t('list.loadErrorForbiddenTitle')} - {viewAccess.refusal.message && {viewAccess.refusal.message}} -
-
- ) : null; + // objectstack#25992 slice 9: the header's create button, folded actions menu and + // the refused-view face moved to `./objectViewHeaderChrome.tsx`. + const { + showCreate, + listFabClearance, + createButton, + importShown, + foldedHeaderActions, + refusedViewFace, + } = useObjectViewHeaderChrome({ + objectCanCreate, + createVisible, + createDisabled, + actions, + t, + listActions, + toolbarActionsWithDeepLink, + identityImportEnabled, + objectCanImport, + importVisible, + importDisabled, + setShowImport, + listFavorite, + activeViewHasPreference, + handleResetView, + isEnvironmentList, + toolbarBar, + viewAccess, + }); return ( diff --git a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx new file mode 100644 index 00000000000..2fab36a245c --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx @@ -0,0 +1,176 @@ +/** + * The header chrome of the Console `ObjectView` (`./ObjectView.tsx`): the + * create button, the header's actions folded into one menu for a narrow + * header, and the list area for a view the server refused, moved out of + * `ObjectViewInner` verbatim (objectstack#25992, slice 9). It calls no React + * hook. Every input is passed in; the component reads the returned values. + */ + +import type { Dispatch, SetStateAction } from 'react'; +import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Plus, Lock } from 'lucide-react'; +import type { ActionBarSchema, UIActionSchema } from '@object-ui/types'; +import { SchemaRenderer } from '@object-ui/react'; +import type { useObjectTranslation } from '@object-ui/i18n'; +import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; +import type { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; +import type { useObjectActions } from '../hooks/useObjectActions.js'; +import { declaredPrimaryToolbarAction, MOBILE_FAB_LIST_CLEARANCE } from './objectViewHelpers.js'; +import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; +import type { useObjectViewIdentity } from './objectViewIdentity.js'; +import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; +import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; + +export function useObjectViewHeaderChrome({ + objectCanCreate, + createVisible, + createDisabled, + actions, + t, + listActions, + toolbarActionsWithDeepLink, + identityImportEnabled, + objectCanImport, + importVisible, + importDisabled, + setShowImport, + listFavorite, + activeViewHasPreference, + handleResetView, + isEnvironmentList, + toolbarBar, + viewAccess, +}: { + objectCanCreate: ReturnType['objectCanCreate']; + createVisible: ReturnType['createVisible']; + createDisabled: ReturnType['createDisabled']; + actions: ReturnType; + t: ReturnType['t']; + listActions: ReturnType['listActions']; + toolbarActionsWithDeepLink: ReturnType['toolbarActionsWithDeepLink']; + identityImportEnabled: ReturnType['identityImportEnabled']; + objectCanImport: ReturnType['objectCanImport']; + importVisible: ReturnType['importVisible']; + importDisabled: ReturnType['importDisabled']; + setShowImport: Dispatch>; + listFavorite: ReturnType; + activeViewHasPreference: ReturnType['activeViewHasPreference']; + handleResetView: ReturnType['handleResetView']; + isEnvironmentList: ReturnType['isEnvironmentList']; + toolbarBar: ReturnType['toolbarBar']; + viewAccess: ReturnType; +}) { + // The header's primary action, drawn the same in both of the header's + // faces (below). [#5153] `objectCanCreate && createVisible` — the + // object-level verdict, then the toolbar-scope `visibleWhen` layer on top + // of it. Greyed, not gone, is the `disabledWhen` case. Same pair drives the + // phone FAB below, so the two render points cannot disagree. + const showCreate = objectCanCreate && createVisible; + // objectui#13134 — the list's end-of-scroll room for the phone FAB, on the + // list host below, exactly while the FAB is drawn (see + // `MOBILE_FAB_LIST_CLEARANCE`). + const listFabClearance = showCreate ? MOBILE_FAB_LIST_CLEARANCE : undefined; + const createButton = showCreate ? ( + + ) : null; + + // objectui#12705 — the header's actions folded, for a header too narrow + // for them and the description: the primary action stays labelled and + // every other action goes into one `⋯` menu, above the page's own entries + // (favorites, Reset view). The header decides when; this decides what. + // + // The primary is DECLARED, never read off the row's order: the page's own + // New when it draws one, else the toolbar action authored + // `variant: 'primary'` (lowest `order` first) — `sys_user`'s Invite User. + // Everything else goes through one `action:bar` with no inline slots, so + // the menu is that bar's one overflow menu: the toolbar actions keep the + // bar's capability, location and `visible` / `disabled` gates and its + // execute path, as when they spill past `maxVisible` today. Import and the + // page's own entries join it as host callbacks, the way the record page's + // menu carries Favorite and Share. + // + // A `?runAction=` deep link still runs once: the arrival's render consumes + // it before the header can fold, so no action in the folded face carries + // `autoTrigger`. Not offered on the environment list, whose toolbar is + // `EnvironmentListToolbar`, not this bar. + const toolbarRowActions: UIActionSchema[] = listActions?.some((a: { locations?: readonly string[] }) => a.locations?.includes('list_toolbar')) + ? toolbarActionsWithDeepLink + : []; + const primaryToolbarAction = showCreate ? undefined : declaredPrimaryToolbarAction(toolbarRowActions); + const importShown = identityImportEnabled || (objectCanImport && importVisible); + const foldedMenuBar: ActionBarSchema = { + type: 'action:bar', + location: 'list_toolbar', + actions: [ + ...(importShown ? [{ + name: 'object_view_import', + label: t('console.objectView.import'), + icon: 'upload', + type: 'script' as const, + locations: ['list_toolbar' as const], + disabled: importDisabled, + onClick: () => setShowImport(true), + }] : []), + ...toolbarRowActions.filter((a) => a.name !== primaryToolbarAction?.name), + ], + systemActions: [ + ...(listFavorite ? [{ + name: 'object_view_favorite', + label: listFavorite.isFavorite + ? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) + : t('common.addToFavorites', { defaultValue: 'Add to favorites' }), + icon: listFavorite.isFavorite ? 'star-off' : 'star', + type: 'script' as const, + onClick: listFavorite.toggle, + }] : []), + ...(activeViewHasPreference ? [{ + name: 'object_view_reset_view', + label: t('console.objectView.resetView'), + icon: 'rotate-ccw', + type: 'script' as const, + onClick: handleResetView, + }] : []), + ], + variant: 'ghost', + size: 'icon', + maxVisible: 0, + mobileMaxVisible: 0, + }; + const foldedHeaderActions = !isEnvironmentList ? ( + <> + {createButton} + {/* One inline slot at every width: `toolbarBar` keeps none below + `md` (`mobileMaxVisible: 0`), which would send the primary into + a second `⋯` beside the menu's. */} + {primaryToolbarAction && ( + + )} + + + ) : undefined; + + // objectui#12112 — the list area for a view the server refused: said as a + // permission refusal, in the server's own sentence, which names the view. + const refusedViewFace = viewAccess.refusal ? ( +
+ +
+ +
+ {t('list.loadErrorForbiddenTitle')} + {viewAccess.refusal.message && {viewAccess.refusal.message}} +
+
+ ) : null; + + return { showCreate, listFabClearance, createButton, importShown, foldedHeaderActions, refusedViewFace }; +} diff --git a/ui/packages/app-shell/src/views/objectViewPluginSchema.ts b/ui/packages/app-shell/src/views/objectViewPluginSchema.ts new file mode 100644 index 00000000000..02266ba3db5 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewPluginSchema.ts @@ -0,0 +1,111 @@ +/** + * The plugin schema of the Console `ObjectView` (`./ObjectView.tsx`): the views + * array with the open draft merged in, and the `ObjectViewSchema` handed to the + * plugin `ObjectView`, with the active view's toolbar policy read from + * `userActions`, moved out of `ObjectViewInner` verbatim (objectstack#25992, + * slice 9). Every input is passed in; the component reads the returned values. + */ + +import { useMemo } from 'react'; +import type { NavigateFunction, Location } from 'react-router-dom'; +import { normalizeListViewSchema } from '@object-ui/core'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewPluginSchema({ + views, + activeViewId, + viewDraft, + activeView, + objectDef, + onEdit, + isAdmin, + location, + navigate, + viewId, + servedViews, + viewLabel, + unlabelledViewText, + language, +}: { + views: ReturnType; + activeViewId: string; + viewDraft: Partial[number]> | null; + activeView: ReturnType[number]; + objectDef: ConsoleObjectViewProps['objects'][number]; + onEdit: ConsoleObjectViewProps['onEdit']; + isAdmin: boolean; + location: Location; + navigate: NavigateFunction; + viewId: string | undefined; + servedViews: ReturnType; + viewLabel: ReturnType['viewLabel']; + unlabelledViewText: string; + language: ReturnType['language']; +}) { + // Memoize the merged views array so PluginObjectView doesn't get a new + // reference on every render (which would trigger unnecessary data refetches). + const mergedViews = useMemo(() => + views.map((v: any) => + v.id === activeViewId && viewDraft && viewDraft.id === v.id + ? { ...v, ...viewDraft } + : v + ), + [views, activeViewId, viewDraft] + ); + + // objectui#11013 — the active view's toolbar policy, read under its declared + // spelling `userActions.search` / `.filter` / `.sort`. These used to be read + // as the bare `showSearch` / `showFilters` / `showSort` flags, so a view + // that declared `userActions: { search: false }` still got a search box + // here. A stored view that still carries a bare flag is folded onto + // `userActions` by `normalizeListViewSchema`, the one fold the relay's own + // `userActions` rung already runs; this read names no legacy key. Not + // memoised: `activeView` is a fresh object on every render while a draft is + // open, and the schema below keys on the three booleans, not on this value. + const activeViewUserActions = (normalizeListViewSchema(activeView ?? {}) as { + userActions?: { search?: boolean; filter?: boolean; sort?: boolean }; + }).userActions; + const activeViewSearch = activeViewUserActions?.search !== false; + const activeViewFilter = activeViewUserActions?.filter !== false; + const activeViewSort = activeViewUserActions?.sort !== false; + + // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) + const objectViewSchema = useMemo(() => ({ + type: 'object-view' as const, + objectName: objectDef.name, + layout: 'page' as const, + showSearch: activeViewSearch, + showFilters: activeViewFilter, + showSort: activeViewSort, + showCreate: false, // We render our own create button in the header + allowCreateView: isAdmin, + viewActions: isAdmin ? [ + { type: 'settings' as const }, + { type: 'share' as const }, + { type: 'delete' as const }, + ] : [], + onNavigate: (recordId: string | number, mode: 'view' | 'edit') => { + if (mode === 'edit') { + onEdit?.({ id: recordId }); + } else if (mode === 'view') { + const originState = { + from: { + pathname: location.pathname + (location.search || ''), + label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + }, + }; + if (viewId) { + navigate(`../../record/${encodeURIComponent(String(recordId))}`, { relative: 'path', state: originState }); + } else { + navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); + } + } + }, + }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.id, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, unlabelledViewText, servedViews, language]); + + return { mergedViews, objectViewSchema }; +} diff --git a/ui/packages/app-shell/src/views/objectViewTabBar.tsx b/ui/packages/app-shell/src/views/objectViewTabBar.tsx new file mode 100644 index 00000000000..59b905e1b66 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewTabBar.tsx @@ -0,0 +1,142 @@ +/** + * The view tab strip of the Console `ObjectView` (`./ObjectView.tsx`): the tab + * items, the `ViewTabBar` handed to the list toolbar as `toolbarLeading`, and + * its own-row face for the views that have no list toolbar, moved out of + * `ObjectViewInner` verbatim (objectstack#25992, slice 9). Every input is + * passed in; the component reads the returned values. + */ + +import type { Dispatch, SetStateAction } from 'react'; +import { ViewTabBar } from '@object-ui/plugin-view'; +import type { ViewTabBarProps, ViewTabItem } from '@object-ui/plugin-view'; +import { useDisplayLocale, pickLocalized } from '@object-ui/i18n'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { viewRowId } from '../utils/viewIdentity.js'; +import type { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import { VIEW_TYPE_ICONS } from './objectViewHelpers.js'; +import { useViewTabsFit } from './objectViewChrome.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewTabBar({ + views, + savedViews, + objectDef, + servedViews, + viewLabel, + unlabelledViewText, + language, + t, + viewAccess, + activeViewId, + handleViewChange, + isAdmin, + handleAddView, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleConfigView, + setManageViewsOpen, +}: { + views: ReturnType; + savedViews: Parameters[0]['savedViews']; + objectDef: ConsoleObjectViewProps['objects'][number]; + servedViews: ReturnType; + viewLabel: ReturnType['viewLabel']; + unlabelledViewText: string; + language: ReturnType['language']; + t: ReturnType['t']; + viewAccess: ReturnType; + activeViewId: ViewTabBarProps['activeViewId']; + handleViewChange: ViewTabBarProps['onViewChange']; + isAdmin: boolean; + handleAddView: NonNullable; + handleRenameView: NonNullable; + handleDeleteView: NonNullable; + handlePinView: NonNullable; + handleSetDefaultView: NonNullable; + handleConfigView: NonNullable; + setManageViewsOpen: Dispatch>; +}) { + /* + * View Tabs — Airtable-style switcher with rename / delete / duplicate / + * pin / set-default / drag-reorder. Built-in views (sourced from + * objectDef.listViews) only support switching; mutating callbacks + * short-circuit with a toast. + * + * objectui#12440 — the strip is no longer a row of its own above the list: + * it is handed to `ListView` as its `toolbarLeading` host prop, so the tabs + * and the list's Filter · Sort · Display · Search share one row. From + * `sm` up only; below it nothing is handed over and the app bar's mobile + * view switcher (registered above) stays the phone's view control. + */ + const viewTabsFit = useViewTabsFit(); + // objectui#12528 — the view's description rides on its tab (the active + // tab's tooltip and ▾ menu header) instead of a row above the toolbar. + // Resolved the way `ListView` resolves the same text (`pickLocalized` on + // the display locale), and gated on the same switch the relay below hands + // `ListView` as `appearance.showDescription`: `false` shows it nowhere. + const displayLocale = useDisplayLocale(); + const viewTabItems: ViewTabItem[] = views.map((view: any) => { + const saved = savedViews.find((sv: any) => viewRowId(sv) === view.id); + // System views (loaded from objectDef.listViews / metadata) are + // *read-only*. Only sys_view-backed records can be mutated by + // the user; admins must duplicate a system view to customize it. + const isSystem = !saved; + return { + id: view.id, + label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + type: view.type, + hasActiveFilters: Array.isArray(view.filter) && view.filter.length > 0, + hasActiveSort: Array.isArray(view.sort) && view.sort.length > 0, + isDefault: !!(saved?.isDefault ?? view.isDefault), + isPinned: !!(saved?.isPinned ?? view.isPinned), + visibility: saved?.visibility ?? view.visibility, + readonly: isSystem, + readonlyReason: isSystem + ? t('console.objectView.systemViewReadonly') + : undefined, + description: (view.showDescription ?? view.appearance?.showDescription) === false + ? undefined + : pickLocalized(view.description, displayLocale) || undefined, + } as ViewTabItem; + }); + const viewTabBar = viewTabsFit && views.length >= 1 ? ( + setManageViewsOpen(true) : undefined} + /> + ) : null; + + // objectui#13114 — the strip as a row of its own, for the faces that have + // no `ListView` toolbar to carry it: a view the server refused (drawn + // page-level below) and a `type: 'chart'` view (drawn above the chart in + // `renderListView`'s chart branch, which returns before `ListView`). Every + // other face gets the strip in the list's toolbar row instead, so no face + // ever draws two. Gated on `viewTabBar`, so below `sm` it is absent too. + const viewTabRow = viewTabBar ? ( +
+ {viewTabBar} +
+ ) : null; + + return { viewTabItems, viewTabBar, viewTabRow }; +} From 472a4e662bf6f5586ca1a185e44c6a7f27d5781f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:52:30 +0000 Subject: [PATCH 09/11] refactor(app-shell): move ObjectView's page header and record overlay into sibling sub-components Move-only split of ObjectViewInner's JSX: the PageHeader block goes to ObjectViewPageHeader in objectViewPageHeader.tsx, and the non-split NavigationOverlay with its RecordDetailView goes to ObjectViewRecordOverlay in objectViewRecordOverlay.tsx. Every value is passed in as a prop; the rendered DOM is unchanged. Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- ...92-objectview-page-header-overlay-split.md | 12 ++ .../app-shell/src/views/ObjectView.tsx | 185 ++++------------- .../src/views/objectViewPageHeader.tsx | 187 ++++++++++++++++++ .../src/views/objectViewRecordOverlay.tsx | 61 ++++++ 4 files changed, 297 insertions(+), 148 deletions(-) create mode 100644 .changeset/25992-objectview-page-header-overlay-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewPageHeader.tsx create mode 100644 ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx diff --git a/.changeset/25992-objectview-page-header-overlay-split.md b/.changeset/25992-objectview-page-header-overlay-split.md new file mode 100644 index 00000000000..14a66af6d7c --- /dev/null +++ b/.changeset/25992-objectview-page-header-overlay-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s page header and record overlay move into sibling sub-components + +Clause-②: no +Metadata-protocol: none + +Two blocks of `ObjectViewInner`'s JSX in `views/ObjectView.tsx` move verbatim into sub-components. The page header (title, managed-by badge, icon, create button, Import, the toolbar actions, the overflow menu and the folded face) moves into `ObjectViewPageHeader` in `objectViewPageHeader.tsx`. The record detail overlay drawn for every navigation mode but `split` moves into `ObjectViewRecordOverlay` in `objectViewRecordOverlay.tsx`. The component passes every value in as a prop. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 657bf3eea4a..f469dfe870d 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -9,7 +9,7 @@ * - ListView delegation for non-grid view types (kanban, calendar, chart, etc.) */ -import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, createElement, type ReactNode } from 'react'; +import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ReactNode } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom'; import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; import { buildListFilterKey, writeListFilterState } from './listFilterStorage.js'; @@ -27,20 +27,14 @@ import { ObjectView as PluginObjectView, ManageViewsDialog } from '@object-ui/pl // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here. import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, NavigationOverlay, } from '@object-ui/components'; -import { Plus, Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; -import { FavoriteMenuItem, useObjectListFavorite } from '../layout/SidebarItemMenu.js'; +import { Plus } from 'lucide-react'; +import { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; import { viewTabLabel } from './viewTabLabel.js'; -import { getIcon } from '../utils/getIcon.js'; import type { DataSource, ListViewSchema, TreeViewConfig } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; @@ -53,10 +47,8 @@ import { PREVIEW_QUERY_FLAG, PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; -import { PageHeader } from '../layout/PageHeader.js'; import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext.js'; import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js'; -import { ManagedByBadge } from '../components/ManagedByBadge.js'; import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; @@ -71,10 +63,9 @@ import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { ActionProvider, SchemaRenderer, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; +import { ActionProvider, useActionTextLocalizer, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; -import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js'; import { VIEW_TYPE_ICONS, FALLBACK_USER, @@ -108,6 +99,8 @@ import { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectView import { useObjectViewTabBar } from './objectViewTabBar.js'; import { useObjectViewPluginSchema } from './objectViewPluginSchema.js'; import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; +import { ObjectViewPageHeader } from './objectViewPageHeader.js'; +import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -1984,120 +1977,26 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co chrome that real-app conventions (Gmail / Notion / Linear) reclaim for content. The create / import / overflow actions move to a floating action button (see below). */} -
- } - subtitle={objectDef.description ? objectDesc(objectDef) : undefined} - // `createElement`, not a JSX tag: the looked-up icon is a value, - // which react-hooks/static-components refuses in tag position - // (the convention ObjectDataPage set). - icon={createElement(getIcon((objectDef as any)?.icon), { className: 'h-4 w-4' })} - actions={ - <> - {/* No favorite star here since objectui#12423: the one-row - header is icon, title, description and these actions. - The favorites entry is in the overflow menu at the end - of this row (objectui#12427). */} - - {/* Primary action (see `createButton`). */} - {createButton} - - {/* Data import — desktop only on phones. CSV imports - are inherently a desk/laptop workflow; the button - was eating header space on mobile next to the - primary "+" action. */} - {/* [#5142] `objectCanImport && importVisible` — the object-level - verdict, then the toolbar-scope `visibleWhen` layer on top of - it. Greyed, not gone, is the `disabledWhen` case below. */} - {importShown && ( - - )} - - {/* Schema-driven toolbar actions */} - {listActions?.some((a: any) => a.locations?.includes('list_toolbar')) && ( - isEnvironmentList ? ( - - ) : ( - - ) - )} - - {/* The page's overflow menu (objectui#12427), last in the - row: the favorites entry that replaced the header star. - Favorites is a once-and-forget act, so it lives behind - the ⋯ rather than in the header's most prominent slot. - "Reset view" (objectui#12418) is the same kind of act and - lives beside it, so the one-row header keeps its shape - as the user sorts. The ⋯ is drawn when either applies. */} - {(listFavorite || activeViewHasPreference) && ( - - - - - - {listFavorite && ( - - )} - {/* objectui#12418 — the user's own list state for - this view (sort, filters, column layout) back to - the view's. Only while they have stored some. */} - {activeViewHasPreference && ( - - - {t('console.objectView.resetView')} - - )} - - - )} - - {/* - Design Tools dropdown removed — its items were redundant: - - "Edit view" → use chevron menu's "Edit view config" - - "Add view" → use the `+` button on the tab bar or the "Add new view" footer in Manage Views - - "Metadata inspector" → still toggleable via the `?__debug` URL flag (see useMetadataInspector) - */} - - } - foldedActions={foldedHeaderActions} + {/* objectstack#25992 slice 10: the header moved to `./objectViewPageHeader.tsx`. */} + -
{/* Floating "+" action button — phone-only counterpart to the PageHeader's primary create action we just hid. Positioned @@ -2344,28 +2243,18 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co {/* Record Detail Overlay — navigation mode driven by objectDef.navigation */} {navOverlay.mode !== 'split' && ( - { if (!open) handleDrawerClose(); }} - title={objectLabel(objectDef)} - onExpand={handleExpandDrawer} - expandLabel={t('console.objectView.expandToPage', { defaultValue: 'Open as full page' })} - storageKey={`drawer-width:${objectDef.name}`} - > - {(record: Record) => { - const recordId = (record.id || record._id) as string; - return ( - - ); - }} - + /* objectstack#25992 slice 10: the overlay moved to `./objectViewRecordOverlay.tsx`. */ + )} {actionRuntime.dialogs} diff --git a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx new file mode 100644 index 00000000000..d10a02b9516 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx @@ -0,0 +1,187 @@ +/** + * The page header of the Console `ObjectView` (`./ObjectView.tsx`): the + * object's plural title, managed-by badge, description and icon, with the + * header's actions (the create button, Import, the schema-driven toolbar and + * the overflow menu with favorites and "Reset view") and their folded face, + * moved out of `ObjectViewInner`'s JSX verbatim (objectstack#25992, slice 10). + * Every value is passed in as a prop. + */ + +import { createElement } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@object-ui/components'; +import { Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; +import { SchemaRenderer } from '@object-ui/react'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { FavoriteMenuItem } from '../layout/SidebarItemMenu.js'; +import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; +import { getIcon } from '../utils/getIcon.js'; +import { PageHeader } from '../layout/PageHeader.js'; +import { ManagedByBadge } from '../components/ManagedByBadge.js'; +import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js'; +import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; +import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; +import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; +import type { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function ObjectViewPageHeader({ + objectDef, + objectPluralLabel, + objectDesc, + createButton, + importShown, + setShowImport, + importDisabled, + t, + listActions, + isEnvironmentList, + headerToolbarActions, + environmentEntitlements, + toolbarBar, + listFavorite, + activeViewHasPreference, + handleResetView, + foldedHeaderActions, +}: { + objectDef: ConsoleObjectViewProps['objects'][number]; + objectPluralLabel: ReturnType['objectPluralLabel']; + objectDesc: ReturnType['objectDescription']; + createButton: ReturnType['createButton']; + importShown: ReturnType['importShown']; + setShowImport: Dispatch>; + importDisabled: ReturnType['importDisabled']; + t: ReturnType['t']; + listActions: ReturnType['listActions']; + isEnvironmentList: ReturnType['isEnvironmentList']; + headerToolbarActions: ReturnType['headerToolbarActions']; + environmentEntitlements: ReturnType['environmentEntitlements']; + toolbarBar: ReturnType['toolbarBar']; + listFavorite: ReturnType; + activeViewHasPreference: ReturnType['activeViewHasPreference']; + handleResetView: ReturnType['handleResetView']; + foldedHeaderActions: ReturnType['foldedHeaderActions']; +}) { + return ( +
+ } + subtitle={objectDef.description ? objectDesc(objectDef) : undefined} + // `createElement`, not a JSX tag: the looked-up icon is a value, + // which react-hooks/static-components refuses in tag position + // (the convention ObjectDataPage set). + icon={createElement(getIcon((objectDef as any)?.icon), { className: 'h-4 w-4' })} + actions={ + <> + {/* No favorite star here since objectui#12423: the one-row + header is icon, title, description and these actions. + The favorites entry is in the overflow menu at the end + of this row (objectui#12427). */} + + {/* Primary action (see `createButton`). */} + {createButton} + + {/* Data import — desktop only on phones. CSV imports + are inherently a desk/laptop workflow; the button + was eating header space on mobile next to the + primary "+" action. */} + {/* [#5142] `objectCanImport && importVisible` — the object-level + verdict, then the toolbar-scope `visibleWhen` layer on top of + it. Greyed, not gone, is the `disabledWhen` case below. */} + {importShown && ( + + )} + + {/* Schema-driven toolbar actions */} + {listActions?.some((a: any) => a.locations?.includes('list_toolbar')) && ( + isEnvironmentList ? ( + + ) : ( + + ) + )} + + {/* The page's overflow menu (objectui#12427), last in the + row: the favorites entry that replaced the header star. + Favorites is a once-and-forget act, so it lives behind + the ⋯ rather than in the header's most prominent slot. + "Reset view" (objectui#12418) is the same kind of act and + lives beside it, so the one-row header keeps its shape + as the user sorts. The ⋯ is drawn when either applies. */} + {(listFavorite || activeViewHasPreference) && ( + + + + + + {listFavorite && ( + + )} + {/* objectui#12418 — the user's own list state for + this view (sort, filters, column layout) back to + the view's. Only while they have stored some. */} + {activeViewHasPreference && ( + + + {t('console.objectView.resetView')} + + )} + + + )} + + {/* + Design Tools dropdown removed — its items were redundant: + - "Edit view" → use chevron menu's "Edit view config" + - "Add view" → use the `+` button on the tab bar or the "Add new view" footer in Manage Views + - "Metadata inspector" → still toggleable via the `?__debug` URL flag (see useMetadataInspector) + */} + + } + foldedActions={foldedHeaderActions} + /> +
+ ); +} diff --git a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx new file mode 100644 index 00000000000..e62bd702ba5 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx @@ -0,0 +1,61 @@ +/** + * The record detail overlay of the Console `ObjectView` (`./ObjectView.tsx`) + * for every navigation mode but `split` (drawer, modal, popover): the + * `NavigationOverlay` that draws the opened record's `RecordDetailView`, moved + * out of `ObjectViewInner`'s JSX verbatim (objectstack#25992, slice 10). The + * `split` face stays in the component, where it wraps the list itself. Every + * value is passed in as a prop. + */ + +import { NavigationOverlay } from '@object-ui/components'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { RecordDetailView } from './RecordDetailView.js'; +import type { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function ObjectViewRecordOverlay({ + navOverlay, + handleDrawerClose, + objectLabel, + objectDef, + handleExpandDrawer, + t, + dataSource, + objects, + onEdit, +}: { + navOverlay: ReturnType['navOverlay']; + handleDrawerClose: ReturnType['handleDrawerClose']; + objectLabel: ReturnType['objectLabel']; + objectDef: ConsoleObjectViewProps['objects'][number]; + handleExpandDrawer: ReturnType['handleExpandDrawer']; + t: ReturnType['t']; + dataSource: ConsoleObjectViewProps['dataSource']; + objects: ConsoleObjectViewProps['objects']; + onEdit: ConsoleObjectViewProps['onEdit']; +}) { + return ( + { if (!open) handleDrawerClose(); }} + title={objectLabel(objectDef)} + onExpand={handleExpandDrawer} + expandLabel={t('console.objectView.expandToPage', { defaultValue: 'Open as full page' })} + storageKey={`drawer-width:${objectDef.name}`} + > + {(record: Record) => { + const recordId = (record.id || record._id) as string; + return ( + + ); + }} + + ); +} From ed7146bf89c9a9a58c6a1977c7e66ce10d1de8a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:03:55 +0000 Subject: [PATCH 10/11] refactor(app-shell): move ObjectView's renderListView into a sibling hook Move-only split of ObjectViewInner: renderListView, the column-declaration flags and the held list identity refs only it reads, and the lazy ObjectChart it mounts go to useObjectViewListRenderer in objectViewListRenderer.tsx, called where renderListView stood. Six source-scan tests read the new file (path or file-list changes only). Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .../25992-objectview-list-renderer-split.md | 12 + .../views/ObjectView.overlayPatchOnly.test.ts | 2 +- .../ObjectView.relayRungCensus-7559.test.ts | 6 +- .../ObjectView.titleFieldConvergence.test.tsx | 2 +- .../app-shell/src/views/ObjectView.tsx | 863 +---------------- .../src/views/objectViewListRenderer.tsx | 915 ++++++++++++++++++ .../views/view-filter-fold.ratchet.test.ts | 2 +- .../object-view-unmirrored-keys-7779.test.ts | 2 +- .../tree-view-config-readers-8253.test.ts | 2 +- 9 files changed, 975 insertions(+), 831 deletions(-) create mode 100644 .changeset/25992-objectview-list-renderer-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewListRenderer.tsx diff --git a/.changeset/25992-objectview-list-renderer-split.md b/.changeset/25992-objectview-list-renderer-split.md new file mode 100644 index 00000000000..36e184fae8a --- /dev/null +++ b/.changeset/25992-objectview-list-renderer-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s `renderListView` moves into a sibling hook + +Clause-②: no +Metadata-protocol: none + +`renderListView`, the callback the plugin `ObjectView` calls to draw a view through `ListView` (and a chart view through `ObjectChart`), moves verbatim out of `ObjectViewInner` in `views/ObjectView.tsx` into `useObjectViewListRenderer` in `objectViewListRenderer.tsx`. The column-declaration flags and the held list identity refs that only it reads move with it. The component passes every input in and reads the returned callback. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts b/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts index a922a9945fb..ea2fd17d39e 100644 --- a/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts @@ -308,7 +308,7 @@ describe('objectui#5233 — what must NOT be narrowed', () => { */ describe('objectui#12504 census — the toolbar-to-view write family is retired', () => { const here = path.dirname(fileURLToPath(import.meta.url)); - const objectViewCode = stripComments(readFileSync(path.join(here, 'ObjectView.tsx'), 'utf8')); + const objectViewCode = stripComments(['ObjectView.tsx', 'objectViewListRenderer.tsx'].map((file) => readFileSync(path.join(here, file), 'utf8')).join('\n')); const dataSourceCode = stripComments( readFileSync(path.join(here, '..', '..', '..', 'types', 'src', 'data.ts'), 'utf8'), ); diff --git a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 7cadf5c590e..3cc5b70a54d 100644 --- a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -131,7 +131,7 @@ const sourceOf = (fromRoot: string, fromPackage: string): string => { return readFileSync(path, 'utf8'); }; -const RELAY_SOURCE = sourceOf('packages/app-shell/src/views/ObjectView.tsx', 'src/views/ObjectView.tsx'); +const RELAY_SOURCE = sourceOf('packages/app-shell/src/views/objectViewListRenderer.tsx', 'src/views/objectViewListRenderer.tsx'); const UPSTREAM_SOURCE = sourceOf('packages/plugin-view/src/ObjectView.tsx', '../plugin-view/src/ObjectView.tsx'); const TYPES_SOURCE = sourceOf('packages/types/src/objectql.ts', '../types/src/objectql.ts'); const LIST_VIEW_SOURCE = sourceOf('packages/plugin-list/src/ListView.tsx', '../plugin-list/src/ListView.tsx'); @@ -225,7 +225,7 @@ const collectLiteral = (literal: ts.ObjectLiteralExpression, prefix: string, out }; const relayLiteral = ((): ts.ObjectLiteralExpression => { - const sf = parse('ObjectView.tsx', RELAY_SOURCE); + const sf = parse('objectViewListRenderer.tsx', RELAY_SOURCE); let found: ts.ObjectLiteralExpression | null = null; const visit = (node: ts.Node): void => { if ( @@ -244,7 +244,7 @@ const relayLiteral = ((): ts.ObjectLiteralExpression => { if (!found) { throw new Error( 'objectui#7559 census cannot find the relay literal `const fullSchema = { … }` in\n' - + 'app-shell/src/views/ObjectView.tsx. The census anchors on that name. If the literal was\n' + + 'app-shell/src/views/objectViewListRenderer.tsx. The census anchors on that name. If the literal was\n' + 'renamed or moved, re-point this anchor — ⛔ do not delete the census, which is the only\n' + 'thing standing between a dropped rung and silence (objectui#7199, #7218, #7516).', ); diff --git a/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx b/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx index e4c8bc1dd07..0a8846a0604 100644 --- a/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx @@ -314,7 +314,7 @@ describe('ObjectView view-config `titleField` — the middle rung is gone (objec describe('the seven seams share ONE expression shape (objectui#6557)', () => { // This face spans `ObjectView.tsx` and the view-options family it imports // from `objectViewOptions.ts` (objectstack#25992 slice 2), so the scan reads both. - const SOURCE = ['ObjectView.tsx', 'objectViewOptions.ts'] + const SOURCE = ['ObjectView.tsx', 'objectViewOptions.ts', 'objectViewListRenderer.tsx'] .map((file) => readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file), 'utf8')) .join('\n'); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index f469dfe870d..667ec7e9932 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -9,19 +9,14 @@ * - ListView delegation for non-grid view types (kanban, calendar, chart, etc.) */ -import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ReactNode } from 'react'; +import { useMemo, useState, useCallback, useEffect, lazy, Suspense } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom'; -import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; -import { buildListFilterKey, writeListFilterState } from './listFilterStorage.js'; -import { useObjectListPreferences, type ListViewPreference } from '../context/ListPreferencesProvider.js'; +import { resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; +import { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; import { formatMetadataError } from '@object-ui/data-objectstack'; -const ObjectChart = lazy(() => - import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), -); const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })), ); -import { ListView } from '@object-ui/plugin-list'; import { ObjectView as PluginObjectView, ManageViewsDialog } from '@object-ui/plugin-view'; // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). @@ -35,7 +30,7 @@ import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; import { viewTabLabel } from './viewTabLabel.js'; -import type { DataSource, ListViewSchema, TreeViewConfig } from '@object-ui/types'; +import type { DataSource } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; @@ -55,10 +50,8 @@ import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js'; import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; -import { resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; import { resolveViewId } from '../utils/resolveViewId.js'; import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js'; -import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js'; import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; @@ -69,7 +62,6 @@ import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; import { VIEW_TYPE_ICONS, FALLBACK_USER, - substituteFilterTokens, } from './objectViewHelpers.js'; import { defaultListColumnsFromObject, @@ -80,16 +72,9 @@ import { buildViewConfigSaveBody, } from './objectViewTabs.js'; import { - timelineViewOptions, - calendarViewOptions, - ganttViewOptions, - galleryViewOptions, - kanbanViewOptions, - storedLegacyOptions, - isSameOptionsValue, dispatchViewPatches, } from './objectViewOptions.js'; -import { ObjectNotFoundState, keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import { ObjectNotFoundState } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; import { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; @@ -101,6 +86,7 @@ import { useObjectViewPluginSchema } from './objectViewPluginSchema.js'; import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import { ObjectViewPageHeader } from './objectViewPageHeader.js'; import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; +import { useObjectViewListRenderer } from './objectViewListRenderer.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -780,31 +766,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co localizedToolbarActions, isEnvironmentList, }); - // objectui#10694 — does the active view, as AUTHORED, declare a non-empty - // `columns`? Booleans, so the relay keys on values and not on the memo's - // Set (AGENTS.md #10). - // - // The TAB answers from its stored body: its own non-empty `columns`, never - // the defaults the views memo drew into it. - const activeTabDeclaresColumns = !!baseView && !derivedColumnViewIds.has(baseView.id) - && Array.isArray(baseView.columns) && baseView.columns.length > 0; - // A config-panel DRAFT answers only with `columns` the admin changed. The - // panel seeds its draft from `activeView` and relays every field on every - // edit (and again on Discard), so an untouched draft carries the tab's - // columns verbatim — on an unprojected tab, the memo's drawn defaults. - // Compared by content, never by identity (AGENTS.md #10). - const draftColumnsEdited = !!viewDraft && viewDraft.id === baseView?.id && 'columns' in viewDraft - && !isSameOptionsValue(viewDraft.columns, baseView?.columns); - const activeViewDeclaresColumns = draftColumnsEdited - ? Array.isArray(viewDraft!.columns) && viewDraft!.columns.length > 0 - : activeTabDeclaresColumns; - // objectui#12505 — is the active tab a SAVED view (a row the adapter's - // `listViews` serves), by the classifier every mutating handler asks - // (`isSavedViewId`)? It decides where the user's own Hide Fields choice - // applies on a tab that declares no projection (see `persistHiddenFields`). - // A boolean, so the relay keys on a value (AGENTS.md #10). - const activeViewIsSaved = !!baseView && isSavedViewId(savedViews, baseView.id); - // objectstack#25992 slice 8: the list's toolbar state in the URL (seed, write-back, // "Reset view") moved to `./objectViewListUrlState.ts`. const { @@ -1106,18 +1067,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co ownListParamsRef, }); - // objectui#10046 — the last `options` bag handed to `ListView`, so an - // equal-content rebuild keeps its identity (see `isSameOptionsValue`). - // A ref, not a `useMemo`: React may discard a memo cache and hand back a - // fresh object (AGENTS.md #10); a ref survives every re-render, and the - // value comparison — not the ref — is what decides a real change. - const heldListOptions = useRef(undefined); - // objectui#7237 — the same hold for the resolved `filter`, same reason: - // token substitution hands back a NEW array of identical content on every - // call, and `ListView` names `schema.filter` by identity in its fetch - // effect, so each host re-render re-issued the identical list query. - const heldListFilter = useRef(undefined); - // objectstack#25992 slice 9: the view tab items, the `ViewTabBar` strip and its // own-row face moved to `./objectViewTabBar.tsx`. const { viewTabItems, viewTabBar, viewTabRow } = useObjectViewTabBar({ @@ -1142,772 +1091,40 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setManageViewsOpen, }); - // Render multi-view content via ListView plugin (for kanban, calendar, etc.) - const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className, refreshKey: pluginRefreshKey }: any) => { - // Combine local refreshKey with the plugin ObjectView's refreshKey for full propagation - const combinedRefreshKey = refreshKey + (pluginRefreshKey || 0); - // objectui#10035 — AGENTS.md #8's corollary: refresh data, don't - // rebuild UI. The counter above is a DATA signal, and it used to ride - // in the `key` of everything below, so every save, delete, import or - // realtime event threw the list away — its search box, open popovers, - // scroll, selection and in-list visualization choice with it. - // `identityKey` is what a remount is for (another object, another - // view), and it is the whole key: `ListView` receives the counter as - // `refreshTrigger`, which its fetch effect names, and the - // visualizations that query for themselves (`gantt`, `chart`) refetch - // in place on the data-invalidation bus (see `refreshData`). ⛔ Do not - // put the counter back in the key. - // `listIdentityKey` is this same identity plus the "Reset view" - // generation (objectui#12418), which is the one other remount. - const identityKey = listIdentityKey ?? `${objectName}-${activeView.id}`; - const viewDef = activeView; - - // Per-user, per-view search-term cache. Restored into ListView at mount - // so leaving via a nav link and coming back keeps what the user was - // searching — URL params alone don't survive that (the nav link has no - // query string). Keyed on the signed-in user so two accounts on one - // browser never share it. The Filter panel's conditions are the user's - // list state since objectui#12418 (`listPrefs`), not this cache. - const listFilterKey = buildListFilterKey(user?.id, objectName, activeView.id); - // objectui#12418 — this user's own state for this view: its sort and - // filters arrive through the seed below, its column layout here. - const viewPref: ListViewPreference | undefined = listPrefs.preference.perView?.[viewDef.id]; - const persistListSort = (sort: Array<{ field: string; order?: string }>) => { - listPrefs.patchView(viewDef.id, { sort }); - }; - const persistListColumnState = (state: { order?: string[]; widths?: Record }) => { - listPrefs.patchView(viewDef.id, { columnState: state }); - }; - // objectui#11860 — what the list opens with: the URL's state, else this - // cache (see `listSeedRef`). Same identity as the list's `key`. - const listSeed = listSeedRef.current?.key === identityKey ? listSeedRef.current.state : undefined; - - // Warn in dev mode if flat properties are used instead of nested spec format. - // ⛔ Only keys some spec per-kind block DECLARES (objectui#6152 round 15). - // This list also named `dateField`, `groupBy`, `groupField`, `imageField`, - // `xAxisField`, `subjectField`, `endField`, `cardFields`, `subtitleField`, - // `yAxisFields`, `aggregation` and `series`, and told an author to move - // them into the block, where every door refuses them by name and no - // reader reads them (objectui#6152 rounds 14 and 15). - if (process.env.NODE_ENV === 'development') { - const flatKeys = ['startDateField', 'endDateField', 'locationField', 'chartType', - 'dependenciesField', 'progressField', 'colorField', 'allDayField', - 'latitudeField', 'longitudeField', 'zoom', 'center', 'descriptionField']; - const nestedConfig = (viewDef as any)[viewDef.type] || {}; - const found = flatKeys.filter(k => k in viewDef && !(k in nestedConfig)); - if (found.length > 0) { - console.warn( - `[Spec Compliance] View "${viewDef.id}" uses flat properties ${JSON.stringify(found)}. ` + - `Move them under viewDef.${viewDef.type || ''} per @objectstack/spec protocol.` - ); - } - } - - /** - * objectui#7891 — this branch forwards the AUTHORED `chart:` block, and - * `config` is NOT part of it. Do not add a `config:` rung back. - * - * `@objectstack/spec`'s `ListChartConfigSchema` is a `strictObject` - * declaring exactly `chartType` / `dataset` / `dimensions` / `values`, - * and objectui binds it BY REFERENCE (`chart` is absent from - * `LIST_VIEW_LOCAL_OVERRIDES`), so a `config` written on this block is - * refused BY NAME — `unrecognized_keys ["config"]` — by the same schema - * the platform's metadata write door parses every save through - * (`saveMetaItem` -> `getMetadataTypeSchema('view')` -> 422 - * INVALID_METADATA, draft and publish alike). No conforming author can - * write the key, and no write path can store it. - * - * Why nothing ever caught the forward: `ObjectChart` is published as - * `(props: any)`, so this literal is checked against nothing whatever. - * The `as any` casts that used to sit on both literals were measured - * INERT — `tsc --noEmit` is green without them — so they are gone too; - * they read as load-bearing and were not. - * - * Losing the key is a DEGRADE, not a break: `ChartRenderer` generates a - * container config from `series` plus a positional palette when none is - * present, so any non-conforming row still renders, with series-derived - * labels and default colours. - * - * Pinned by `ObjectView.chartConfigForward-7891.test.tsx`. - */ - if (viewDef.type === 'chart') { - const chartConfig = viewDef.chart || {}; - // objectui#13114 — this branch returns before `ListView`, the - // carrier of the view tabs on every other face, so it draws them - // itself (`viewTabRow`) above the chart: without them a chart view - // is a dead end with no way back to the grid. - const withViewTabs = (chartFace: ReactNode) => ( -
- {viewTabRow} - {chartFace} -
- ); - // Both chart branches below are keyed on `identityKey` alone - // (objectui#10035): `ObjectChart` runs its own query and refetches - // in place on the data-invalidation bus (see `refreshData`). - // - // ADR-0021 (objectstack-ai/objectstack#1890): dataset-bound chart — the single author-facing - // shape. Selects dimensions/measures BY NAME and runs through the - // governed queryDataset path (numbers consistent across surfaces). - if (chartConfig.dataset) { - const dims: string[] = Array.isArray(chartConfig.dimensions) ? chartConfig.dimensions : []; - const vals: string[] = Array.isArray(chartConfig.values) ? chartConfig.values : []; - return withViewTabs( - {t('chart.loading')}}> - ({ dataKey: v, label: v })), - // no `config` rung — see the block above (#7891) - className: 'h-[400px] w-full', - }} - /> - - ); - } - // ⛔ NO LEGACY INLINE AGGREGATE (objectui#6152 round 15). A block - // that names no `dataset` used to be translated here from the - // pre-ADR-0021 axes (`xAxisField`, `yAxisFields[0]`, `aggregation`) - // plus a `series` and a `filter` read off the block, floored at - // `'name'` / `'value'` when it declared none. The spec's - // `ListChartConfigSchema` refuses all five by name at every door, so - // the path retired with them, floors included — the same retirement - // as `ListView`'s `case 'chart'` and `generateViewSchema`'s. - // - // What is left is the UNBOUND chart: an object-bound node that names - // no category, which `ObjectChart` refuses on screen (objectui#8168, - // `chart-missing-category-axis`) instead of aggregating on a name - // nobody wrote. ⛔ Do not restore a floor here. - return withViewTabs( - {t('chart.loading')}}> - - - ); - } - - // objectui#12418 — the list opens on the user's stored sort and - // filters, which are read in the console's one preference read; until - // that read has answered, there is nothing to open on yet. - if (!listPrefs.ready) { - return
; - } - - // ADR-0053 wrong-context authoring: userFilters/quickFilters on an - // object list view are suppressed below — say so instead of letting - // the author stare at a toolbar with nothing where their filter - // controls should be (#2219). - warnSuppressedListNav(objectDef.name, viewDef.id || viewDef.name || '', viewDef as any, listSchema as any); - - // objectui#7029: present only when the view actually declared one. - const calendarOptions = calendarViewOptions(viewDef); - // objectui#10380: the stored row's judged legacy `options` bag. See - // `storedLegacyOptions` for how the relay below layers it. - const legacyOptions = storedLegacyOptions(viewDef); - - /** - * objectui#12474 — the Hide Fields popover's choice is the USER's: it - * is stored on their own list state (`listPrefs`, beside the sort and - * the column layout), never written into the shared view, so one - * user's click no longer hides a column for everyone and a walled - * tenant member is not refused. It is a SUBTRACTION from the columns - * the view declares: the popover offers only those (and, objectui#11984, - * only the ones the caller may read), and the list draws `columns` - * minus this list, so it can never add a column. - * - * On a view that declares no projection (objectui#10694: the views - * memo DRAWS default columns into it, and the relay applies none of - * the VIEW's `hiddenFields` there, see `activeViewDeclaresColumns`) - * the rule splits by the kind of view: - * - * - A SAVED view (objectui#12505): the choice is stored all the same, - * and the relay's `hiddenFields` rung applies it — this user's list - * only, never the view's authored key — as a subtraction from the - * columns the view draws by default. It never adds a column: a - * stored name the drawn set lacks (a field since removed, a column - * the view later stops drawing) matches nothing and stays inert. - * Another user, with no list of their own, sees the defaults. - * - A SYSTEM view, or the console-made fallback tab: session-only, - * per objectui#10694's ruling 5839344270 (B), whose operative - * sentence names "an object-route view that declares no projection - * and is not a saved view". `ListView` still hides the column in its - * own state, and nothing is stored. The author's remedy there is to - * declare `columns`. - */ - const persistHiddenFields = (hidden: string[]) => { - if (!activeViewDeclaresColumns && !activeViewIsSaved) return; - listPrefs.patchView(viewDef.id, { hiddenFields: hidden }); - }; - - /** - * objectui#12949 — the VIEW'S OWN density and hidden fields, before - * this user's preference (objectui#12474) is laid over them by the - * `rowHeight` and `hiddenFields` rungs below. `ListView` reads these - * through its `viewDefaults` prop: the Display control's count and its - * "Reset to view default" compare the user's list against the view's, - * not against the user's own stored choice. On a view that declares no - * `columns` the view's authored `hiddenFields` is never applied - * (objectui#10694, objectui#12505), so the view hides nothing there. - */ - const viewRowHeight: ListViewSchema['rowHeight'] = - (normalizeListViewSchema(viewDef ?? {}) as Pick).rowHeight - ?? listSchema.rowHeight; - const viewHiddenFields: ListViewSchema['hiddenFields'] = activeViewDeclaresColumns - ? ((viewDef as any).hiddenFields ?? listSchema.hiddenFields) - : undefined; - - - /** - * ⚠️ THE RELAY. Every key below is a rung carrying the ACTIVE VIEW's - * value into `ListView`; `...listSchema` carries the HOST's. - * - * A `ListViewSchema` member with NO rung here is invisible to tsc, to - * lint and to the tests — `viewDef` is `Record`, so - * nothing REQUIRES a key to be written. That silence shipped the same - * defect three times (objectui#7199 `description`, objectui#7218 - * `rowColor`, objectui#7516 `fieldOrder` — all three carry their rung - * below now), and objectui#7559 ended it: - * `ObjectView.relayRungCensus-7559.test.ts` re-derives the member set - * from the zod mirror at test time and requires every member to have - * either a rung here or a DECLARED absence with a reason. - * - * ⇒ Adding a member to `ListViewSchema` and not relaying it is fine — - * but it must be said out loud, in that file's `ABSENCES` ledger. ⛔ The - * ledger is not a place to record a rung you did not feel like adding: - * the kinds carry evidence, and each one is checked. - */ - const fullSchema: ListViewSchema = { - ...listSchema, - // objectui#10035 — the refresh signal, in place of the remount the - // key used to force. The spread above carries only the plugin - // ObjectView's counter; this page's own counter (actions, import, - // realtime, view edits, the console's `externalRefreshKey`) reached - // `ListView` through the key alone. Both counters only grow, so - // their sum moves whenever either does. - refreshTrigger: combinedRefreshKey, - // The active view's display label (same string the ViewTabBar - // shows) — ListView appends it to export download filenames. - label: viewDef.label ?? listSchema.label, - /** - * The active view's own description — the sentence the author wrote - * to caveat THIS view (scope, staleness, "this lens is for - * browsing, the dashboard is authoritative"), which is exactly the - * text a per-view description is wanted for (objectui#7199). - * - * It was the one key of this relay's set with no rung, so - * `schema.description` at the `ListView` end could only ever be the - * object-level list's description and a per-view one was - * unreachable — authored, validated, built and served, then - * silently dropped here. Nothing errored: the value simply never - * arrived, and the only symptom was a sentence missing from the - * screen. - * - * ⚠️ NOT the object's own `objectDef.description`, which this page - * renders as the `PageHeader` subtitle further down. Crossing the - * two would put a view's caveat where the object's blurb belongs. - * Same two-rung shape as `label` above. - */ - description: viewDef.description ?? listSchema.description, - // Propagate appearance/view-config properties for live preview. - // objectui#7924 (ruling A′): the view's density is read THROUGH the - // fold, view over list, the same route as the `userActions` rung - // below. A stored view that still spells it `densityMode` arrives - // here as `rowHeight`, and this relay never names the retired key. - // objectui#12474 — the density this user chose is laid over it: - // the view's own is the default for a user who has not chosen. - // The fold stays spelled out on this rung, where the relay census - // (`ObjectView.relayRungCensus-7559.test.ts`) reads it for the - // retired `densityMode`; `viewRowHeight` above is the same value. - rowHeight: - viewPref?.rowHeight - ?? (normalizeListViewSchema(viewDef ?? {}) as Pick).rowHeight - ?? listSchema.rowHeight, - // Hydrate the persisted settings so they survive reload. The sort - // and the column layout (objectui#12418), the density and the - // hidden fields (objectui#12474) are the USER's: they are read - // from and written to `listPrefs`, never `/meta/view`. No list - // toolbar control writes the shared view; the view editor's - // explicit save does. - // - // objectui#11860 — a sort the URL carries is applied over the - // view's: an opened link sorts as it was sorted. Without a link, - // the seed carries the sort this user left the view with - // (objectui#12418). `ListView` reads this as the list's declared - // sort too, so its "reset to default" returns to the sort the list - // opened with; "Reset view" returns to the view's own. - sort: listSeed?.sort ?? (viewDef as any).sort ?? listSchema.sort, - // objectui#11860 — and a grouping the URL carries is laid over the - // view's, which the caller composed into `listSchema` (the census - // declares `grouping` relayed upstream; this reads no `viewDef`). - // `ListView` seeds its toolbar grouping from this value. - grouping: listSeed?.grouping ?? listSchema.grouping, - // The ONE place this view's effective filter is computed (#2890). - // It used to be computed twice — once here as `filter` for the child - // views, once further down as `filters` for ListView — with the two - // copies subtly different (only this one fell back to - // `listSchema.filter`; only that one ran token substitution over the - // URL filters). Now: base ?? listSchema, concatenated with the URL - // filters, and substituted as a whole. - filter: (() => { - const base = (viewDef as any).filter ?? listSchema.filter; - const baseArr = Array.isArray(base) ? base : base ? [base] : []; - const combined = urlFilters.length ? [...baseArr, ...urlFilters] : base; - const resolved = substituteFilterTokens(combined, filterScope); - // objectui#7237 — equal content keeps the identity `ListView` - // names (and hands the self-querying views); a real change — - // another URL filter, another session scope, a date macro that - // resolves differently — still hands over the new value. - if (isSameOptionsValue(heldListFilter.current, resolved)) { - return heldListFilter.current; - } - heldListFilter.current = resolved; - return resolved; - })(), - /** - * objectui#10694 — both composition keys apply only when the view - * declares a non-empty `columns`. The views memo draws defaults - * into an unprojected view, and `ListViewSchema.columns` (spec - * source at objectstack `origin/main`, objectstack#19598) says an - * empty list "declares no projection, so neither of them applies". - * The same gate `InterfaceListPage` puts on a source view - * (objectui#10638), so the two routes compose a view one way. The - * value is set to `undefined`, not omitted: `...listSchema` above - * already carries the host's echo of the view's keys. - */ - hiddenFields: activeViewDeclaresColumns - // objectui#12474 — this user's own hidden fields over the - // view's (a subtraction from the same `columns`). - ? (viewPref?.hiddenFields ?? viewHiddenFields) - // objectui#12505 — on an unprojected SAVED view, this user's - // own list alone, subtracted from the drawn defaults; the - // view's authored key stays unapplied, as above. See - // `persistHiddenFields` for why a system view gets none. - : activeViewIsSaved - ? viewPref?.hiddenFields - : undefined, - /** - * The per-view ORDERING of the field composition - * `columns` x `hiddenFields` x `fieldOrder` (objectui#7516) — - * objectstack#15184 ruling B kept the key and wrote the - * composition into the contract: `columns` projects, - * `hiddenFields` (the rung above) subtracts, `fieldOrder` sorts - * what survives, an unlisted survivor sorting last. `ListView`'s - * `effectiveFields` memo runs those steps on `schema.fieldOrder`; - * this rung only delivers the view's value into that slot, the - * one the list-node spelling fills, so the two compose alike. - * - * View over list node — the precedence of the `hiddenFields` rung - * above; a view that authors no order keeps the list node's. It - * was the one per-view half of the composition with no rung: - * authored, served, then dropped here. - */ - fieldOrder: activeViewDeclaresColumns - ? (viewDef.fieldOrder ?? listSchema.fieldOrder) - : undefined, - // objectui#12418 — the user's column layout over the view's. `{}` - // when neither has one: this host persists the layout, so the - // grid's own per-browser fallback must not stand in for it (and a - // "Reset view" must reach the grid). - columnState: viewPref?.columnState ?? (viewDef as any).columnState ?? (listSchema as any).columnState ?? {}, - onDensityChange: (mode) => { - // objectui#12474 — the USER's density, on their own list state, - // never the shared view. Stored as the spec-canonical - // `rowHeight` (#2890), the vocabulary the rung above reads. - listPrefs.patchView(viewDef.id, { - rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[mode], - }); - }, - onSortChange: persistListSort, - // NO `onFilterChange` (objectui#4155): the filter panel is session - // state. ListView keeps it in `currentFilters` and applies it to the - // live query; nothing about it reaches the stored view. - onHiddenFieldsChange: persistHiddenFields, - onColumnStateChange: persistListColumnState, - // objectui#11013 — `inlineEdit` only. The `editRecordsInline` - // spelling this also read has no producer: no console surface - // writes it and the spec's view schema refuses it by name. - inlineEdit: viewDef.inlineEdit ?? listSchema.inlineEdit, - // ADR-0047 — spec `appearance` (incl. allowedVisualizations, the - // runtime visualization whitelist) flows from the view metadata; - // the legacy bare `showDescription` flag is folded in on top. - appearance: viewDef.appearance - ? (viewDef.showDescription != null - ? { ...viewDef.appearance, showDescription: viewDef.showDescription } - : viewDef.appearance) - : (viewDef.showDescription != null - ? { showDescription: viewDef.showDescription } - : listSchema.appearance), - // The author's INTENT to offer a visualization switcher: more than - // one type whitelisted. Deliberately not the final predicate — this - // face counts the whitelist, and the whitelist is not the offer. - // - // `ListView` intersects it with the capability gate and draws the - // chrome only when the INTERSECTION has more than one entry - // (objectui#7547, `viewSwitcherOffered`). It has to be decided - // there: the gate that knows which types actually resolve lives in - // that component, so a view whitelisting `['grid', 'timeline']` - // with no timeline block reads as two here and one there. Counting - // this number alone is what drew switcher chrome around a single - // Grid entry. - showViewSwitcher: - ((viewDef.appearance ?? listSchema.appearance)?.allowedVisualizations?.length ?? 0) > 1, - // Toolbar policy — one vocabulary (#2890). Stored views may still - // carry the legacy bare `show*` flags, so each side is run through - // `normalizeListViewSchema` (the single fold) and merged, view over - // list. This relay never names a legacy key itself. - userActions: { - ...(normalizeListViewSchema(listSchema ?? {}) as { userActions?: object }).userActions, - ...(normalizeListViewSchema(viewDef ?? {}) as { userActions?: object }).userActions, - }, - // objectui#11013 — no `allowExport` rung, and `exportOptions` is no - // longer withheld on a view's `allowExport: false`. Nothing writes - // `allowExport` onto a view: no console surface does, and the - // spec's view schema refuses it by name (the seat's disposition on - // objectstack#20456: no producer, no declared spelling). The - // export gate `ListView` keeps reading is the HOST's, carried in - // by `...listSchema` from the object-view node. - exportOptions: viewDef.exportOptions ?? listSchema.exportOptions, - color: viewDef.color ?? listSchema.color, - /** - * The spec-canonical row-colour CONFIGURATION the author put on - * THIS view (objectui#7218) — `ListView` seeds `rowColorConfig` - * from it and colours whole rows from the named field's value. - * - * It was the one key of this relay's set with no rung, so - * `schema.rowColor` at the `ListView` end could only ever be what - * the host put on the list schema, and a per-view colour was - * unreachable — authored, validated, built and served, then - * silently dropped here. Same two-rung shape as `description` - * above, and the same shape the interface route has shipped all - * along (`InterfaceListPage.tsx`, `rowColor: view.rowColor`). - * - * The bare `color` above is the LEGACY shorthand for this same - * feature and already had a rung; this adds the canonical one. - * - * NOT `userActions.rowColor`, relayed above through the toolbar - * fold — that is a boolean permission toggle sharing this name at - * a different nesting level ('may the user open the colour panel' - * versus 'what the colours are'). - */ - rowColor: viewDef.rowColor ?? listSchema.rowColor, - // objectui#11013 — the seven renderer flags this relay used to read - // off the active view (`wrapHeaders`, `clickIntoRecordDetails`, - // `addRecordViaForm`, `addDeleteRecordsInline`, - // `collapseAllByDefault`, `fieldTextColor`, `prefixField`) have no - // rung. No console surface writes one onto a view, no view in this - // repository authors one, and the spec's view schema refuses each - // by name, so a stored view never supplies them. What reaches - // `ListView` is the host's value, carried by `...listSchema` from - // the object-view node (the objectui#5097 host-composition keys). - // The census answers each in `ObjectView.relayRungCensus-7559.test.ts`. - // - // ViewData source override (spec `data` key): a view authored with - // `data: {provider:'value', items}` must survive this explicit - // picklist, or ObjectGantt falls back to provider:'object'. - data: (viewDef as any).data ?? (listSchema as any).data, - // Propagate new spec properties (P0/P1/P2) - navigation: viewDef.navigation ?? listSchema.navigation, - selection: viewDef.selection ?? listSchema.selection, - pagination: viewDef.pagination ?? listSchema.pagination, - searchableFields: viewDef.searchableFields ?? listSchema.searchableFields, - resizable: viewDef.resizable ?? listSchema.resizable, - rowActions: viewDef.rowActions ?? listSchema.rowActions, - /** - * Row-context action definitions derived from `objectDef.actions` - * filtered by `locations.includes('list_item')`. These are full - * `ActionDef` records (with label/icon/variant/params/recordIdParam - * /bodyShape) the row menu renders with i18n-correct labels and - * dispatches via the action runner; legacy `rowActions: string[]` - * remains for back-compat where the action lives elsewhere. - */ - rowActionDefs: (Array.isArray((objectDef as any)?.actions) - ? (objectDef as any).actions - .filter((a: any) => - Array.isArray(a?.locations) && a.locations.includes('list_item')) - // Localize label / confirm / success the same way the - // record_header and list_toolbar paths do — through the ONE - // shared resolver (objectui#4265), so this surface cannot - // drift back to a label-only resolution. The `visible` CEL - // is forwarded untouched (spread inside the localizer) and - // evaluated per-row at render time inside RowActionMenu. - .map((a: any) => localizeActionTexts(objectDef.name, a)) - : []), - /** - * Selection-bar actions. Unlike `rowActionDefs` above, these are - * NOT resolved here: `ObjectGrid` is the single convergence point - * of all three list callers (this view, plugin-view's ObjectView - * and plugin-list's ListView), so it resolves each `bulkActions` - * name against `objectDef.actions` and promotes it into the def - * list itself (`resolveBulkActions`, objectui#3002). What we pass - * through is the view author's own declaration. - */ - bulkActions: viewDef.bulkActions ?? listSchema.bulkActions, - bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs, - sharing: viewDef.sharing ?? listSchema.sharing, - // objectui#13089 — the Add button `ListView` draws from - // `addRecord.enabled` opens the header New's create (`onAddRecord` - // below), so it follows the New's verdict: withheld, not drawn - // dead, for a viewer who may not create or whose create is greyed. - addRecord: listAddRecordOpensCreate ? viewDef.addRecord ?? listSchema.addRecord : undefined, - conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting, - // ADR-0047 (amended, objectui #2338): this is the object default - // list = "views" mode. `dropdown` (value-chip) userFilters ARE - // allowed here — they're the Airtable quick-filter pills. Only the - // `tabs` preset style stays page-only (it would collide with the - // ViewTabBar above, which owns the tab-bar role — use `listViews` - // for named presets). `quickFilters` stays suppressed entirely. - quickFilters: undefined, - userFilters: (() => { - const uf = (viewDef.userFilters ?? listSchema.userFilters) as - | { element?: string; tabs?: unknown } - | undefined; - return uf && uf.element !== 'tabs' && uf.tabs == null ? uf : undefined; - })(), - showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount, - allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting, - emptyState: - viewEmptyState( - objectDef.name, - viewDef.name || viewDef.id || '', - viewDef.emptyState - ?? listSchema.emptyState - ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name, (objectDef as any)?.userActions, pageOffersCreate), - ), - aria: viewDef.aria ?? listSchema.aria, - // (the legacy `filters` twin of the `filter` above lived here until - // #2890 — see the note at its single remaining computation) - // - // objectui#11860 — and a second `sort` write lived here, the view's - // own sort spread in AFTER the `sort` rung above. Redundant with - // that rung while it read the view alone; once the rung put a - // URL-carried sort first, this spread overrode it on every view - // that declares a sort, so a link's sort reached the address bar - // and never the query. The rung is the one `sort` write. - // objectui#10380 — for each kind the stored row's legacy `options` - // bag carries, the view's own top-level block goes out at the top - // level, as `InterfaceListPage` sends it. `ListView` then lays it - // over the bag per key: the top-level block wins where both set a - // key, which is the precedence the spec declares for the bag. A kind - // the bag does not carry is relayed only through `options` below, - // exactly as before. See `storedLegacyOptions`. - ...(legacyOptions.kanban !== undefined ? { kanban: viewDef.kanban } : {}), - ...(legacyOptions.calendar !== undefined ? { calendar: viewDef.calendar } : {}), - ...(legacyOptions.timeline !== undefined ? { timeline: viewDef.timeline } : {}), - ...(legacyOptions.map !== undefined ? { map: viewDef.map } : {}), - ...(legacyOptions.gallery !== undefined ? { gallery: viewDef.gallery } : {}), - ...(legacyOptions.gantt !== undefined ? { gantt: viewDef.gantt } : {}), - ...(legacyOptions.tree !== undefined ? { tree: viewDef.tree } : {}), - ...(legacyOptions.chart !== undefined ? { chart: viewDef.chart } : {}), - options: { - // objectui#10380 — the stored row's judged legacy bag, forwarded - // as the interface page forwards it. For each kind the bag - // carries, its block REPLACES this page's synthesized block below - // (the `mapCfg` rule of `InterfaceListPage`). Each synthesized - // block stands in only for a kind the bag does not carry. - ...legacyOptions, - // The lane key is the spec's `groupByField`, not the legacy - // `groupField` this used to write. See `kanbanViewOptions`. - kanban: legacyOptions.kanban ?? kanbanViewOptions(viewDef, objectDef), - // The calendar config the view DECLARED, or no calendar key at - // all — never an invented field name (objectui#7029). With the - // key absent, ListView's capability gate stops offering the - // Calendar toggle for a view that configured none, and a view - // forced onto the calendar renderer reaches its refusal screen. - ...((legacyOptions.calendar ?? calendarOptions) - ? { calendar: legacyOptions.calendar ?? calendarOptions } - : {}), - // The date axis is resolved once, in ListView — this face only - // forwards what the view declared, floored at 'name' - // (objectui#3129, objectui#6557). See `timelineViewOptions`. - timeline: legacyOptions.timeline ?? timelineViewOptions(viewDef), - map: legacyOptions.map ?? { - locationField: viewDef.map?.locationField, - titleField: viewDef.map?.titleField || 'name', - latitudeField: viewDef.map?.latitudeField, - longitudeField: viewDef.map?.longitudeField, - zoom: viewDef.map?.zoom, - center: viewDef.map?.center, - }, - // The gallery config the view DECLARED, title floored at - // 'name', and no invented cover field (objectui#7547). With no - // cover binding to forward, ListView's capability gate stops - // offering the Gallery toggle to a view that configured none. - // See `galleryViewOptions`. - gallery: legacyOptions.gallery ?? galleryViewOptions(viewDef), - // The gantt config the view DECLARED, title floored at 'name' — - // never an invented date field (`5f4514f7b`). With no date - // binding to forward, ListView's capability gate stops offering - // the Gantt toggle to a view that configured none, and a view - // forced onto the gantt renderer reaches its refusal screen. - // See `ganttViewOptions`. - gantt: legacyOptions.gantt ?? ganttViewOptions(viewDef), - tree: legacyOptions.tree ?? { - // Self-referencing tree-grid config (plugin-tree). Spread the - // full view-defined tree first so parentField/fields/ - // defaultExpandedDepth survive; labelField is floored at - // 'name'. parentField auto-detects when omitted. - // - // Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253): - // `viewDef` is `Record`, so the canonical rung - // below was an `any` property access and a misspelling was - // invisible. This is the half of objectui#7559 a declaration CAN - // close, on the one block that now has a declaration to close it - // with — it does NOT make a missing rung visible, which is what - // the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is - // for. - // - // ⚠️ The cast is repeated per rung rather than hoisted into a - // local: objectui#6557's convergence pin reads these seam lines - // out of this file and requires each to name `viewDef` itself. - // - // ⛔ No `titleField` rung (objectui#6152 round 14). `TreeViewConfig` - // is the spec's `ListView.tree` block, which refuses `titleField` - // by name (objectui#8841); the undeclared fallback that still - // read it is retired here and in `ListView` alike, so a stored - // `tree.titleField` labels nothing and the tree falls to 'name'. - ...((viewDef.tree as TreeViewConfig | undefined) || {}), - labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || 'name', - }, - // The chart block the view DECLARED, forwarded WHOLE — a - // pointer, not a copy of its key set (objectui#7823). - // - // This was a hand-listed projection of six keys (`chartType` / - // `xAxisField` / `yAxisFields` / `aggregation` / `series` / - // `config`): the PRE-ADR-0021 set, frozen. The whole ADR-0021 - // (objectstack-ai/objectstack#1890) authoring shape (`dataset` / `dimensions` / `values`) - // and the legacy `categoryField` / `valueField` spelling had no - // rung here, so an author who declared them reached `ListView` - // with the binding stripped. Once objectui#7544 gave - // `ListView.availableViews` a chart capability check, that gate - // was handed six `undefined` keys and correctly answered - // "nothing declared" about a view whose author declared - // everything — the Chart toggle stayed hidden, with no - // diagnostic, while the legacy `xAxisField` spelling (which the - // projection did carry) went on resolving. - // - // ⛔ NOT widened to nine keys. A hand-listed projection is a - // COPY of a schema's key set and copies rot silently: three more - // keys would buy this ADR's correctness and re-arm the identical - // trap for the next block key, with nothing to fire — `viewDef` - // is `Record`, so a missing rung is invisible to - // tsc (objectui#7559 owns that mechanism and disclaims the - // census this belongs to). - // - // Forwarding whole is safe because `ListView` never SPREADS this - // block — `resolveListChartBinding` and `case 'chart'` both read - // it BY NAME — so keys it does not consult are ignored exactly - // as they are for the `gantt` / `timeline` / `tree` blocks above, - // which are already relayed whole. Undeclared stays `undefined` - // rather than the old permanently-truthy husk, which is what - // keeps the gate from offering a chart nobody configured. - chart: legacyOptions.chart ?? viewDef.chart, - }, - }; - // objectui#10046 — equal content keeps the identity `ListView`'s fetch - // effect names; a real change (any differing value) still hands over - // the new bag and still refetches. Pinned by - // `ObjectView.hostRerenderRefetch-10046.test.tsx`. - if (isSameOptionsValue(heldListOptions.current, fullSchema.options)) { - fullSchema.options = heldListOptions.current as ListViewSchema['options']; - } else { - heldListOptions.current = fullSchema.options; - } - - return ( - { - if (record?.id != null) { - // useObjectActions.deleteRecord wraps execute() which - // already shows a confirmation dialog + success toast - // and triggers onRefresh on success. - // Pass the row so the confirmation names the - // record (objectui#11695). - actions.deleteRecord(String(record.id), record); - } - }} - onBulkDelete={(records: any[]) => { - const valid = records.filter((r: any) => r?.id != null); - if (valid.length === 0) return; - // `deleteRecords` confirms once for the whole batch, with the - // shared copy that counts it (objectui#11695), and the - // batch-aware delete handler handles refresh + toast. - actions.deleteRecords(valid); - }} - onRowClick={(record: any, event?: any) => { - handleRowClick(record, event); - }} - onSortChange={(sort: any) => { - // objectui#12418 — this user's sort, not the view's. - persistListSort(sort); - writeListUrlState({ sort }); - }} - // objectui#11860 — the URL only: nothing else stores the - // toolbar grouping, and the view's own stays as authored. - onGroupingChange={(grouping) => writeListUrlState({ grouping: grouping ?? null })} - onFilterChange={(filter: any) => { - // The USER's state (objectui#12418), never the view's - // stored body (objectui#4155) — the user's list state keeps - // the BUILDER's group verbatim, read back into - // `initialFilters`, which needs `conditions` (and the row - // ids) to rehydrate the toolbar. It follows the user to - // another browser, and the panel's own "Clear all" clears - // it: it writes an empty group through this same handler. - listPrefs.patchView(viewDef.id, { filters: filter }); - // objectui#11860 — and the URL, so the list is a link. - writeListUrlState({ filters: filter }); - }} - onSearchChange={(search: string) => { - writeListFilterState(listFilterKey, { search }); - writeListUrlState({ search }); - }} - onHiddenFieldsChange={persistHiddenFields} - // objectui#5144 (ruling E): session-only, writes nothing. - onInlineEditChange={keepInlineEditModeForTheSession} - onColumnStateChange={persistListColumnState} - userFilterSelections={initialUfSelections} - onUserFilterSelectionsChange={handleUserFilterSelectionsChange} - initialFilters={listSeed?.filters} - initialSearchTerm={listSeed?.search} - // objectui#12440 — the saved-view tabs, on the list's own - // toolbar row (see `viewTabBar`). `undefined` below `sm`. - toolbarLeading={viewTabBar ?? undefined} - // objectui#12606 — Setup's list opens without row numbers in - // the leading cell. A host prop, not a view key: a console - // posture, never metadata. - defaultRowNumbers={!inSetup} - // objectui#13330 — on a managed environment, the Users list - // marks a user who holds nothing beyond the baseline. A host - // prop the grid draws beside the row's title. - rowAdornment={managedUsers ? managedUserMarker : undefined} - dataSource={ds} - /> - ); - }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker]); + // objectstack#25992 slice 11a: the column-declaration flags, the held list refs and + // `renderListView` moved to `./objectViewListRenderer.tsx`. + const { renderListView } = useObjectViewListRenderer({ + objectName, + inSetup, + t, + viewEmptyState, + localizeActionTexts, + viewDraft, + user, + objectDef, + refreshKey, + listAddRecordOpensCreate, + pageOffersCreate, + managedUsers, + managedUserMarker, + savedViews, + listPrefs, + derivedColumnViewIds, + baseView, + activeView, + listIdentityKey, + listSeedRef, + writeListUrlState, + filterScope, + actions, + handleRowClick, + navOverlay, + urlFilters, + initialUfSelections, + handleUserFilterSelectionsChange, + viewTabRow, + viewTabBar, + }); // objectstack#25992 slice 9: the merged views and the plugin's `ObjectViewSchema` // moved to `./objectViewPluginSchema.ts`. diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx new file mode 100644 index 00000000000..1a7ce855419 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -0,0 +1,915 @@ +/** + * The list renderer of the Console `ObjectView` (`./ObjectView.tsx`): + * `renderListView`, the callback the plugin `ObjectView` calls to draw a view + * through `ListView` (and the `type: 'chart'` face through `ObjectChart`), with + * the relay of the view's keys onto the list schema, the held list identity + * refs, and the column-declaration flags only it reads, moved out of + * `ObjectViewInner` verbatim (objectstack#25992, slice 11a). Every input is + * passed in; the component reads the returned callback. + */ + +import { useCallback, useRef, lazy, Suspense, type ReactNode } from 'react'; +import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core'; +import { buildListFilterKey, writeListFilterState } from './listFilterStorage.js'; +import type { useObjectListPreferences, ListViewPreference } from '../context/ListPreferencesProvider.js'; +const ObjectChart = lazy(() => + import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), +); +import { ListView } from '@object-ui/plugin-list'; +import type { ListViewSchema, TreeViewConfig } from '@object-ui/types'; +import type { useAuth } from '@object-ui/auth'; +import type { useActionTextLocalizer } from '@object-ui/react'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; +import { isSavedViewId } from '../utils/viewIdentity.js'; +import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js'; +import type { isSetupAppSegment } from '../console/setupApp.js'; +import type { useObjectActions } from '../hooks/useObjectActions.js'; +import { substituteFilterTokens } from './objectViewHelpers.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import { + timelineViewOptions, + calendarViewOptions, + ganttViewOptions, + galleryViewOptions, + kanbanViewOptions, + storedLegacyOptions, + isSameOptionsValue, +} from './objectViewOptions.js'; +import { keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import type { useObjectViewIdentity } from './objectViewIdentity.js'; +import type { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; +import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; +import type { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectViewListUrlState.js'; +import type { useObjectViewTabBar } from './objectViewTabBar.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewListRenderer({ + objectName, + inSetup, + t, + viewEmptyState, + localizeActionTexts, + viewDraft, + user, + objectDef, + refreshKey, + listAddRecordOpensCreate, + pageOffersCreate, + managedUsers, + managedUserMarker, + savedViews, + listPrefs, + derivedColumnViewIds, + baseView, + activeView, + listIdentityKey, + listSeedRef, + writeListUrlState, + filterScope, + actions, + handleRowClick, + navOverlay, + urlFilters, + initialUfSelections, + handleUserFilterSelectionsChange, + viewTabRow, + viewTabBar, +}: { + objectName: string | undefined; + inSetup: ReturnType; + t: ReturnType['t']; + viewEmptyState: ReturnType['viewEmptyState']; + localizeActionTexts: ReturnType; + viewDraft: Partial[number]> | null; + user: ReturnType['user']; + objectDef: ConsoleObjectViewProps['objects'][number]; + refreshKey: number; + listAddRecordOpensCreate: ReturnType['listAddRecordOpensCreate']; + pageOffersCreate: ReturnType['pageOffersCreate']; + managedUsers: ReturnType['managedUsers']; + managedUserMarker: ReturnType['managedUserMarker']; + savedViews: Parameters[0]['savedViews']; + listPrefs: ReturnType; + derivedColumnViewIds: Set; + baseView: ReturnType[number]; + activeView: ReturnType[number]; + listIdentityKey: ReturnType['listIdentityKey']; + listSeedRef: ReturnType['listSeedRef']; + writeListUrlState: ReturnType['writeListUrlState']; + filterScope: FilterTokenScope; + actions: ReturnType; + handleRowClick: ReturnType['handleRowClick']; + navOverlay: ReturnType['navOverlay']; + urlFilters: ReturnType['urlFilters']; + initialUfSelections: ReturnType['initialUfSelections']; + handleUserFilterSelectionsChange: ReturnType['handleUserFilterSelectionsChange']; + viewTabRow: ReturnType['viewTabRow']; + viewTabBar: ReturnType['viewTabBar']; +}) { + // objectui#10694 — does the active view, as AUTHORED, declare a non-empty + // `columns`? Booleans, so the relay keys on values and not on the memo's + // Set (AGENTS.md #10). + // + // The TAB answers from its stored body: its own non-empty `columns`, never + // the defaults the views memo drew into it. + const activeTabDeclaresColumns = !!baseView && !derivedColumnViewIds.has(baseView.id) + && Array.isArray(baseView.columns) && baseView.columns.length > 0; + // A config-panel DRAFT answers only with `columns` the admin changed. The + // panel seeds its draft from `activeView` and relays every field on every + // edit (and again on Discard), so an untouched draft carries the tab's + // columns verbatim — on an unprojected tab, the memo's drawn defaults. + // Compared by content, never by identity (AGENTS.md #10). + const draftColumnsEdited = !!viewDraft && viewDraft.id === baseView?.id && 'columns' in viewDraft + && !isSameOptionsValue(viewDraft.columns, baseView?.columns); + const activeViewDeclaresColumns = draftColumnsEdited + ? Array.isArray(viewDraft!.columns) && viewDraft!.columns.length > 0 + : activeTabDeclaresColumns; + // objectui#12505 — is the active tab a SAVED view (a row the adapter's + // `listViews` serves), by the classifier every mutating handler asks + // (`isSavedViewId`)? It decides where the user's own Hide Fields choice + // applies on a tab that declares no projection (see `persistHiddenFields`). + // A boolean, so the relay keys on a value (AGENTS.md #10). + const activeViewIsSaved = !!baseView && isSavedViewId(savedViews, baseView.id); + + // objectui#10046 — the last `options` bag handed to `ListView`, so an + // equal-content rebuild keeps its identity (see `isSameOptionsValue`). + // A ref, not a `useMemo`: React may discard a memo cache and hand back a + // fresh object (AGENTS.md #10); a ref survives every re-render, and the + // value comparison — not the ref — is what decides a real change. + const heldListOptions = useRef(undefined); + // objectui#7237 — the same hold for the resolved `filter`, same reason: + // token substitution hands back a NEW array of identical content on every + // call, and `ListView` names `schema.filter` by identity in its fetch + // effect, so each host re-render re-issued the identical list query. + const heldListFilter = useRef(undefined); + + // Render multi-view content via ListView plugin (for kanban, calendar, etc.) + const renderListView = useCallback(({ schema: listSchema, dataSource: ds, onEdit: editHandler, className, refreshKey: pluginRefreshKey }: any) => { + // Combine local refreshKey with the plugin ObjectView's refreshKey for full propagation + const combinedRefreshKey = refreshKey + (pluginRefreshKey || 0); + // objectui#10035 — AGENTS.md #8's corollary: refresh data, don't + // rebuild UI. The counter above is a DATA signal, and it used to ride + // in the `key` of everything below, so every save, delete, import or + // realtime event threw the list away — its search box, open popovers, + // scroll, selection and in-list visualization choice with it. + // `identityKey` is what a remount is for (another object, another + // view), and it is the whole key: `ListView` receives the counter as + // `refreshTrigger`, which its fetch effect names, and the + // visualizations that query for themselves (`gantt`, `chart`) refetch + // in place on the data-invalidation bus (see `refreshData`). ⛔ Do not + // put the counter back in the key. + // `listIdentityKey` is this same identity plus the "Reset view" + // generation (objectui#12418), which is the one other remount. + const identityKey = listIdentityKey ?? `${objectName}-${activeView.id}`; + const viewDef = activeView; + + // Per-user, per-view search-term cache. Restored into ListView at mount + // so leaving via a nav link and coming back keeps what the user was + // searching — URL params alone don't survive that (the nav link has no + // query string). Keyed on the signed-in user so two accounts on one + // browser never share it. The Filter panel's conditions are the user's + // list state since objectui#12418 (`listPrefs`), not this cache. + const listFilterKey = buildListFilterKey(user?.id, objectName, activeView.id); + // objectui#12418 — this user's own state for this view: its sort and + // filters arrive through the seed below, its column layout here. + const viewPref: ListViewPreference | undefined = listPrefs.preference.perView?.[viewDef.id]; + const persistListSort = (sort: Array<{ field: string; order?: string }>) => { + listPrefs.patchView(viewDef.id, { sort }); + }; + const persistListColumnState = (state: { order?: string[]; widths?: Record }) => { + listPrefs.patchView(viewDef.id, { columnState: state }); + }; + // objectui#11860 — what the list opens with: the URL's state, else this + // cache (see `listSeedRef`). Same identity as the list's `key`. + const listSeed = listSeedRef.current?.key === identityKey ? listSeedRef.current.state : undefined; + + // Warn in dev mode if flat properties are used instead of nested spec format. + // ⛔ Only keys some spec per-kind block DECLARES (objectui#6152 round 15). + // This list also named `dateField`, `groupBy`, `groupField`, `imageField`, + // `xAxisField`, `subjectField`, `endField`, `cardFields`, `subtitleField`, + // `yAxisFields`, `aggregation` and `series`, and told an author to move + // them into the block, where every door refuses them by name and no + // reader reads them (objectui#6152 rounds 14 and 15). + if (process.env.NODE_ENV === 'development') { + const flatKeys = ['startDateField', 'endDateField', 'locationField', 'chartType', + 'dependenciesField', 'progressField', 'colorField', 'allDayField', + 'latitudeField', 'longitudeField', 'zoom', 'center', 'descriptionField']; + const nestedConfig = (viewDef as any)[viewDef.type] || {}; + const found = flatKeys.filter(k => k in viewDef && !(k in nestedConfig)); + if (found.length > 0) { + console.warn( + `[Spec Compliance] View "${viewDef.id}" uses flat properties ${JSON.stringify(found)}. ` + + `Move them under viewDef.${viewDef.type || ''} per @objectstack/spec protocol.` + ); + } + } + + /** + * objectui#7891 — this branch forwards the AUTHORED `chart:` block, and + * `config` is NOT part of it. Do not add a `config:` rung back. + * + * `@objectstack/spec`'s `ListChartConfigSchema` is a `strictObject` + * declaring exactly `chartType` / `dataset` / `dimensions` / `values`, + * and objectui binds it BY REFERENCE (`chart` is absent from + * `LIST_VIEW_LOCAL_OVERRIDES`), so a `config` written on this block is + * refused BY NAME — `unrecognized_keys ["config"]` — by the same schema + * the platform's metadata write door parses every save through + * (`saveMetaItem` -> `getMetadataTypeSchema('view')` -> 422 + * INVALID_METADATA, draft and publish alike). No conforming author can + * write the key, and no write path can store it. + * + * Why nothing ever caught the forward: `ObjectChart` is published as + * `(props: any)`, so this literal is checked against nothing whatever. + * The `as any` casts that used to sit on both literals were measured + * INERT — `tsc --noEmit` is green without them — so they are gone too; + * they read as load-bearing and were not. + * + * Losing the key is a DEGRADE, not a break: `ChartRenderer` generates a + * container config from `series` plus a positional palette when none is + * present, so any non-conforming row still renders, with series-derived + * labels and default colours. + * + * Pinned by `ObjectView.chartConfigForward-7891.test.tsx`. + */ + if (viewDef.type === 'chart') { + const chartConfig = viewDef.chart || {}; + // objectui#13114 — this branch returns before `ListView`, the + // carrier of the view tabs on every other face, so it draws them + // itself (`viewTabRow`) above the chart: without them a chart view + // is a dead end with no way back to the grid. + const withViewTabs = (chartFace: ReactNode) => ( +
+ {viewTabRow} + {chartFace} +
+ ); + // Both chart branches below are keyed on `identityKey` alone + // (objectui#10035): `ObjectChart` runs its own query and refetches + // in place on the data-invalidation bus (see `refreshData`). + // + // ADR-0021 (objectstack-ai/objectstack#1890): dataset-bound chart — the single author-facing + // shape. Selects dimensions/measures BY NAME and runs through the + // governed queryDataset path (numbers consistent across surfaces). + if (chartConfig.dataset) { + const dims: string[] = Array.isArray(chartConfig.dimensions) ? chartConfig.dimensions : []; + const vals: string[] = Array.isArray(chartConfig.values) ? chartConfig.values : []; + return withViewTabs( + {t('chart.loading')}
}> + ({ dataKey: v, label: v })), + // no `config` rung — see the block above (#7891) + className: 'h-[400px] w-full', + }} + /> + + ); + } + // ⛔ NO LEGACY INLINE AGGREGATE (objectui#6152 round 15). A block + // that names no `dataset` used to be translated here from the + // pre-ADR-0021 axes (`xAxisField`, `yAxisFields[0]`, `aggregation`) + // plus a `series` and a `filter` read off the block, floored at + // `'name'` / `'value'` when it declared none. The spec's + // `ListChartConfigSchema` refuses all five by name at every door, so + // the path retired with them, floors included — the same retirement + // as `ListView`'s `case 'chart'` and `generateViewSchema`'s. + // + // What is left is the UNBOUND chart: an object-bound node that names + // no category, which `ObjectChart` refuses on screen (objectui#8168, + // `chart-missing-category-axis`) instead of aggregating on a name + // nobody wrote. ⛔ Do not restore a floor here. + return withViewTabs( + {t('chart.loading')}}> + + + ); + } + + // objectui#12418 — the list opens on the user's stored sort and + // filters, which are read in the console's one preference read; until + // that read has answered, there is nothing to open on yet. + if (!listPrefs.ready) { + return
; + } + + // ADR-0053 wrong-context authoring: userFilters/quickFilters on an + // object list view are suppressed below — say so instead of letting + // the author stare at a toolbar with nothing where their filter + // controls should be (#2219). + warnSuppressedListNav(objectDef.name, viewDef.id || viewDef.name || '', viewDef as any, listSchema as any); + + // objectui#7029: present only when the view actually declared one. + const calendarOptions = calendarViewOptions(viewDef); + // objectui#10380: the stored row's judged legacy `options` bag. See + // `storedLegacyOptions` for how the relay below layers it. + const legacyOptions = storedLegacyOptions(viewDef); + + /** + * objectui#12474 — the Hide Fields popover's choice is the USER's: it + * is stored on their own list state (`listPrefs`, beside the sort and + * the column layout), never written into the shared view, so one + * user's click no longer hides a column for everyone and a walled + * tenant member is not refused. It is a SUBTRACTION from the columns + * the view declares: the popover offers only those (and, objectui#11984, + * only the ones the caller may read), and the list draws `columns` + * minus this list, so it can never add a column. + * + * On a view that declares no projection (objectui#10694: the views + * memo DRAWS default columns into it, and the relay applies none of + * the VIEW's `hiddenFields` there, see `activeViewDeclaresColumns`) + * the rule splits by the kind of view: + * + * - A SAVED view (objectui#12505): the choice is stored all the same, + * and the relay's `hiddenFields` rung applies it — this user's list + * only, never the view's authored key — as a subtraction from the + * columns the view draws by default. It never adds a column: a + * stored name the drawn set lacks (a field since removed, a column + * the view later stops drawing) matches nothing and stays inert. + * Another user, with no list of their own, sees the defaults. + * - A SYSTEM view, or the console-made fallback tab: session-only, + * per objectui#10694's ruling 5839344270 (B), whose operative + * sentence names "an object-route view that declares no projection + * and is not a saved view". `ListView` still hides the column in its + * own state, and nothing is stored. The author's remedy there is to + * declare `columns`. + */ + const persistHiddenFields = (hidden: string[]) => { + if (!activeViewDeclaresColumns && !activeViewIsSaved) return; + listPrefs.patchView(viewDef.id, { hiddenFields: hidden }); + }; + + /** + * objectui#12949 — the VIEW'S OWN density and hidden fields, before + * this user's preference (objectui#12474) is laid over them by the + * `rowHeight` and `hiddenFields` rungs below. `ListView` reads these + * through its `viewDefaults` prop: the Display control's count and its + * "Reset to view default" compare the user's list against the view's, + * not against the user's own stored choice. On a view that declares no + * `columns` the view's authored `hiddenFields` is never applied + * (objectui#10694, objectui#12505), so the view hides nothing there. + */ + const viewRowHeight: ListViewSchema['rowHeight'] = + (normalizeListViewSchema(viewDef ?? {}) as Pick).rowHeight + ?? listSchema.rowHeight; + const viewHiddenFields: ListViewSchema['hiddenFields'] = activeViewDeclaresColumns + ? ((viewDef as any).hiddenFields ?? listSchema.hiddenFields) + : undefined; + + + /** + * ⚠️ THE RELAY. Every key below is a rung carrying the ACTIVE VIEW's + * value into `ListView`; `...listSchema` carries the HOST's. + * + * A `ListViewSchema` member with NO rung here is invisible to tsc, to + * lint and to the tests — `viewDef` is `Record`, so + * nothing REQUIRES a key to be written. That silence shipped the same + * defect three times (objectui#7199 `description`, objectui#7218 + * `rowColor`, objectui#7516 `fieldOrder` — all three carry their rung + * below now), and objectui#7559 ended it: + * `ObjectView.relayRungCensus-7559.test.ts` re-derives the member set + * from the zod mirror at test time and requires every member to have + * either a rung here or a DECLARED absence with a reason. + * + * ⇒ Adding a member to `ListViewSchema` and not relaying it is fine — + * but it must be said out loud, in that file's `ABSENCES` ledger. ⛔ The + * ledger is not a place to record a rung you did not feel like adding: + * the kinds carry evidence, and each one is checked. + */ + const fullSchema: ListViewSchema = { + ...listSchema, + // objectui#10035 — the refresh signal, in place of the remount the + // key used to force. The spread above carries only the plugin + // ObjectView's counter; this page's own counter (actions, import, + // realtime, view edits, the console's `externalRefreshKey`) reached + // `ListView` through the key alone. Both counters only grow, so + // their sum moves whenever either does. + refreshTrigger: combinedRefreshKey, + // The active view's display label (same string the ViewTabBar + // shows) — ListView appends it to export download filenames. + label: viewDef.label ?? listSchema.label, + /** + * The active view's own description — the sentence the author wrote + * to caveat THIS view (scope, staleness, "this lens is for + * browsing, the dashboard is authoritative"), which is exactly the + * text a per-view description is wanted for (objectui#7199). + * + * It was the one key of this relay's set with no rung, so + * `schema.description` at the `ListView` end could only ever be the + * object-level list's description and a per-view one was + * unreachable — authored, validated, built and served, then + * silently dropped here. Nothing errored: the value simply never + * arrived, and the only symptom was a sentence missing from the + * screen. + * + * ⚠️ NOT the object's own `objectDef.description`, which this page + * renders as the `PageHeader` subtitle further down. Crossing the + * two would put a view's caveat where the object's blurb belongs. + * Same two-rung shape as `label` above. + */ + description: viewDef.description ?? listSchema.description, + // Propagate appearance/view-config properties for live preview. + // objectui#7924 (ruling A′): the view's density is read THROUGH the + // fold, view over list, the same route as the `userActions` rung + // below. A stored view that still spells it `densityMode` arrives + // here as `rowHeight`, and this relay never names the retired key. + // objectui#12474 — the density this user chose is laid over it: + // the view's own is the default for a user who has not chosen. + // The fold stays spelled out on this rung, where the relay census + // (`ObjectView.relayRungCensus-7559.test.ts`) reads it for the + // retired `densityMode`; `viewRowHeight` above is the same value. + rowHeight: + viewPref?.rowHeight + ?? (normalizeListViewSchema(viewDef ?? {}) as Pick).rowHeight + ?? listSchema.rowHeight, + // Hydrate the persisted settings so they survive reload. The sort + // and the column layout (objectui#12418), the density and the + // hidden fields (objectui#12474) are the USER's: they are read + // from and written to `listPrefs`, never `/meta/view`. No list + // toolbar control writes the shared view; the view editor's + // explicit save does. + // + // objectui#11860 — a sort the URL carries is applied over the + // view's: an opened link sorts as it was sorted. Without a link, + // the seed carries the sort this user left the view with + // (objectui#12418). `ListView` reads this as the list's declared + // sort too, so its "reset to default" returns to the sort the list + // opened with; "Reset view" returns to the view's own. + sort: listSeed?.sort ?? (viewDef as any).sort ?? listSchema.sort, + // objectui#11860 — and a grouping the URL carries is laid over the + // view's, which the caller composed into `listSchema` (the census + // declares `grouping` relayed upstream; this reads no `viewDef`). + // `ListView` seeds its toolbar grouping from this value. + grouping: listSeed?.grouping ?? listSchema.grouping, + // The ONE place this view's effective filter is computed (#2890). + // It used to be computed twice — once here as `filter` for the child + // views, once further down as `filters` for ListView — with the two + // copies subtly different (only this one fell back to + // `listSchema.filter`; only that one ran token substitution over the + // URL filters). Now: base ?? listSchema, concatenated with the URL + // filters, and substituted as a whole. + filter: (() => { + const base = (viewDef as any).filter ?? listSchema.filter; + const baseArr = Array.isArray(base) ? base : base ? [base] : []; + const combined = urlFilters.length ? [...baseArr, ...urlFilters] : base; + const resolved = substituteFilterTokens(combined, filterScope); + // objectui#7237 — equal content keeps the identity `ListView` + // names (and hands the self-querying views); a real change — + // another URL filter, another session scope, a date macro that + // resolves differently — still hands over the new value. + if (isSameOptionsValue(heldListFilter.current, resolved)) { + return heldListFilter.current; + } + heldListFilter.current = resolved; + return resolved; + })(), + /** + * objectui#10694 — both composition keys apply only when the view + * declares a non-empty `columns`. The views memo draws defaults + * into an unprojected view, and `ListViewSchema.columns` (spec + * source at objectstack `origin/main`, objectstack#19598) says an + * empty list "declares no projection, so neither of them applies". + * The same gate `InterfaceListPage` puts on a source view + * (objectui#10638), so the two routes compose a view one way. The + * value is set to `undefined`, not omitted: `...listSchema` above + * already carries the host's echo of the view's keys. + */ + hiddenFields: activeViewDeclaresColumns + // objectui#12474 — this user's own hidden fields over the + // view's (a subtraction from the same `columns`). + ? (viewPref?.hiddenFields ?? viewHiddenFields) + // objectui#12505 — on an unprojected SAVED view, this user's + // own list alone, subtracted from the drawn defaults; the + // view's authored key stays unapplied, as above. See + // `persistHiddenFields` for why a system view gets none. + : activeViewIsSaved + ? viewPref?.hiddenFields + : undefined, + /** + * The per-view ORDERING of the field composition + * `columns` x `hiddenFields` x `fieldOrder` (objectui#7516) — + * objectstack#15184 ruling B kept the key and wrote the + * composition into the contract: `columns` projects, + * `hiddenFields` (the rung above) subtracts, `fieldOrder` sorts + * what survives, an unlisted survivor sorting last. `ListView`'s + * `effectiveFields` memo runs those steps on `schema.fieldOrder`; + * this rung only delivers the view's value into that slot, the + * one the list-node spelling fills, so the two compose alike. + * + * View over list node — the precedence of the `hiddenFields` rung + * above; a view that authors no order keeps the list node's. It + * was the one per-view half of the composition with no rung: + * authored, served, then dropped here. + */ + fieldOrder: activeViewDeclaresColumns + ? (viewDef.fieldOrder ?? listSchema.fieldOrder) + : undefined, + // objectui#12418 — the user's column layout over the view's. `{}` + // when neither has one: this host persists the layout, so the + // grid's own per-browser fallback must not stand in for it (and a + // "Reset view" must reach the grid). + columnState: viewPref?.columnState ?? (viewDef as any).columnState ?? (listSchema as any).columnState ?? {}, + onDensityChange: (mode) => { + // objectui#12474 — the USER's density, on their own list state, + // never the shared view. Stored as the spec-canonical + // `rowHeight` (#2890), the vocabulary the rung above reads. + listPrefs.patchView(viewDef.id, { + rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[mode], + }); + }, + onSortChange: persistListSort, + // NO `onFilterChange` (objectui#4155): the filter panel is session + // state. ListView keeps it in `currentFilters` and applies it to the + // live query; nothing about it reaches the stored view. + onHiddenFieldsChange: persistHiddenFields, + onColumnStateChange: persistListColumnState, + // objectui#11013 — `inlineEdit` only. The `editRecordsInline` + // spelling this also read has no producer: no console surface + // writes it and the spec's view schema refuses it by name. + inlineEdit: viewDef.inlineEdit ?? listSchema.inlineEdit, + // ADR-0047 — spec `appearance` (incl. allowedVisualizations, the + // runtime visualization whitelist) flows from the view metadata; + // the legacy bare `showDescription` flag is folded in on top. + appearance: viewDef.appearance + ? (viewDef.showDescription != null + ? { ...viewDef.appearance, showDescription: viewDef.showDescription } + : viewDef.appearance) + : (viewDef.showDescription != null + ? { showDescription: viewDef.showDescription } + : listSchema.appearance), + // The author's INTENT to offer a visualization switcher: more than + // one type whitelisted. Deliberately not the final predicate — this + // face counts the whitelist, and the whitelist is not the offer. + // + // `ListView` intersects it with the capability gate and draws the + // chrome only when the INTERSECTION has more than one entry + // (objectui#7547, `viewSwitcherOffered`). It has to be decided + // there: the gate that knows which types actually resolve lives in + // that component, so a view whitelisting `['grid', 'timeline']` + // with no timeline block reads as two here and one there. Counting + // this number alone is what drew switcher chrome around a single + // Grid entry. + showViewSwitcher: + ((viewDef.appearance ?? listSchema.appearance)?.allowedVisualizations?.length ?? 0) > 1, + // Toolbar policy — one vocabulary (#2890). Stored views may still + // carry the legacy bare `show*` flags, so each side is run through + // `normalizeListViewSchema` (the single fold) and merged, view over + // list. This relay never names a legacy key itself. + userActions: { + ...(normalizeListViewSchema(listSchema ?? {}) as { userActions?: object }).userActions, + ...(normalizeListViewSchema(viewDef ?? {}) as { userActions?: object }).userActions, + }, + // objectui#11013 — no `allowExport` rung, and `exportOptions` is no + // longer withheld on a view's `allowExport: false`. Nothing writes + // `allowExport` onto a view: no console surface does, and the + // spec's view schema refuses it by name (the seat's disposition on + // objectstack#20456: no producer, no declared spelling). The + // export gate `ListView` keeps reading is the HOST's, carried in + // by `...listSchema` from the object-view node. + exportOptions: viewDef.exportOptions ?? listSchema.exportOptions, + color: viewDef.color ?? listSchema.color, + /** + * The spec-canonical row-colour CONFIGURATION the author put on + * THIS view (objectui#7218) — `ListView` seeds `rowColorConfig` + * from it and colours whole rows from the named field's value. + * + * It was the one key of this relay's set with no rung, so + * `schema.rowColor` at the `ListView` end could only ever be what + * the host put on the list schema, and a per-view colour was + * unreachable — authored, validated, built and served, then + * silently dropped here. Same two-rung shape as `description` + * above, and the same shape the interface route has shipped all + * along (`InterfaceListPage.tsx`, `rowColor: view.rowColor`). + * + * The bare `color` above is the LEGACY shorthand for this same + * feature and already had a rung; this adds the canonical one. + * + * NOT `userActions.rowColor`, relayed above through the toolbar + * fold — that is a boolean permission toggle sharing this name at + * a different nesting level ('may the user open the colour panel' + * versus 'what the colours are'). + */ + rowColor: viewDef.rowColor ?? listSchema.rowColor, + // objectui#11013 — the seven renderer flags this relay used to read + // off the active view (`wrapHeaders`, `clickIntoRecordDetails`, + // `addRecordViaForm`, `addDeleteRecordsInline`, + // `collapseAllByDefault`, `fieldTextColor`, `prefixField`) have no + // rung. No console surface writes one onto a view, no view in this + // repository authors one, and the spec's view schema refuses each + // by name, so a stored view never supplies them. What reaches + // `ListView` is the host's value, carried by `...listSchema` from + // the object-view node (the objectui#5097 host-composition keys). + // The census answers each in `ObjectView.relayRungCensus-7559.test.ts`. + // + // ViewData source override (spec `data` key): a view authored with + // `data: {provider:'value', items}` must survive this explicit + // picklist, or ObjectGantt falls back to provider:'object'. + data: (viewDef as any).data ?? (listSchema as any).data, + // Propagate new spec properties (P0/P1/P2) + navigation: viewDef.navigation ?? listSchema.navigation, + selection: viewDef.selection ?? listSchema.selection, + pagination: viewDef.pagination ?? listSchema.pagination, + searchableFields: viewDef.searchableFields ?? listSchema.searchableFields, + resizable: viewDef.resizable ?? listSchema.resizable, + rowActions: viewDef.rowActions ?? listSchema.rowActions, + /** + * Row-context action definitions derived from `objectDef.actions` + * filtered by `locations.includes('list_item')`. These are full + * `ActionDef` records (with label/icon/variant/params/recordIdParam + * /bodyShape) the row menu renders with i18n-correct labels and + * dispatches via the action runner; legacy `rowActions: string[]` + * remains for back-compat where the action lives elsewhere. + */ + rowActionDefs: (Array.isArray((objectDef as any)?.actions) + ? (objectDef as any).actions + .filter((a: any) => + Array.isArray(a?.locations) && a.locations.includes('list_item')) + // Localize label / confirm / success the same way the + // record_header and list_toolbar paths do — through the ONE + // shared resolver (objectui#4265), so this surface cannot + // drift back to a label-only resolution. The `visible` CEL + // is forwarded untouched (spread inside the localizer) and + // evaluated per-row at render time inside RowActionMenu. + .map((a: any) => localizeActionTexts(objectDef.name, a)) + : []), + /** + * Selection-bar actions. Unlike `rowActionDefs` above, these are + * NOT resolved here: `ObjectGrid` is the single convergence point + * of all three list callers (this view, plugin-view's ObjectView + * and plugin-list's ListView), so it resolves each `bulkActions` + * name against `objectDef.actions` and promotes it into the def + * list itself (`resolveBulkActions`, objectui#3002). What we pass + * through is the view author's own declaration. + */ + bulkActions: viewDef.bulkActions ?? listSchema.bulkActions, + bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs, + sharing: viewDef.sharing ?? listSchema.sharing, + // objectui#13089 — the Add button `ListView` draws from + // `addRecord.enabled` opens the header New's create (`onAddRecord` + // below), so it follows the New's verdict: withheld, not drawn + // dead, for a viewer who may not create or whose create is greyed. + addRecord: listAddRecordOpensCreate ? viewDef.addRecord ?? listSchema.addRecord : undefined, + conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting, + // ADR-0047 (amended, objectui #2338): this is the object default + // list = "views" mode. `dropdown` (value-chip) userFilters ARE + // allowed here — they're the Airtable quick-filter pills. Only the + // `tabs` preset style stays page-only (it would collide with the + // ViewTabBar above, which owns the tab-bar role — use `listViews` + // for named presets). `quickFilters` stays suppressed entirely. + quickFilters: undefined, + userFilters: (() => { + const uf = (viewDef.userFilters ?? listSchema.userFilters) as + | { element?: string; tabs?: unknown } + | undefined; + return uf && uf.element !== 'tabs' && uf.tabs == null ? uf : undefined; + })(), + showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount, + allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting, + emptyState: + viewEmptyState( + objectDef.name, + viewDef.name || viewDef.id || '', + viewDef.emptyState + ?? listSchema.emptyState + ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name, (objectDef as any)?.userActions, pageOffersCreate), + ), + aria: viewDef.aria ?? listSchema.aria, + // (the legacy `filters` twin of the `filter` above lived here until + // #2890 — see the note at its single remaining computation) + // + // objectui#11860 — and a second `sort` write lived here, the view's + // own sort spread in AFTER the `sort` rung above. Redundant with + // that rung while it read the view alone; once the rung put a + // URL-carried sort first, this spread overrode it on every view + // that declares a sort, so a link's sort reached the address bar + // and never the query. The rung is the one `sort` write. + // objectui#10380 — for each kind the stored row's legacy `options` + // bag carries, the view's own top-level block goes out at the top + // level, as `InterfaceListPage` sends it. `ListView` then lays it + // over the bag per key: the top-level block wins where both set a + // key, which is the precedence the spec declares for the bag. A kind + // the bag does not carry is relayed only through `options` below, + // exactly as before. See `storedLegacyOptions`. + ...(legacyOptions.kanban !== undefined ? { kanban: viewDef.kanban } : {}), + ...(legacyOptions.calendar !== undefined ? { calendar: viewDef.calendar } : {}), + ...(legacyOptions.timeline !== undefined ? { timeline: viewDef.timeline } : {}), + ...(legacyOptions.map !== undefined ? { map: viewDef.map } : {}), + ...(legacyOptions.gallery !== undefined ? { gallery: viewDef.gallery } : {}), + ...(legacyOptions.gantt !== undefined ? { gantt: viewDef.gantt } : {}), + ...(legacyOptions.tree !== undefined ? { tree: viewDef.tree } : {}), + ...(legacyOptions.chart !== undefined ? { chart: viewDef.chart } : {}), + options: { + // objectui#10380 — the stored row's judged legacy bag, forwarded + // as the interface page forwards it. For each kind the bag + // carries, its block REPLACES this page's synthesized block below + // (the `mapCfg` rule of `InterfaceListPage`). Each synthesized + // block stands in only for a kind the bag does not carry. + ...legacyOptions, + // The lane key is the spec's `groupByField`, not the legacy + // `groupField` this used to write. See `kanbanViewOptions`. + kanban: legacyOptions.kanban ?? kanbanViewOptions(viewDef, objectDef), + // The calendar config the view DECLARED, or no calendar key at + // all — never an invented field name (objectui#7029). With the + // key absent, ListView's capability gate stops offering the + // Calendar toggle for a view that configured none, and a view + // forced onto the calendar renderer reaches its refusal screen. + ...((legacyOptions.calendar ?? calendarOptions) + ? { calendar: legacyOptions.calendar ?? calendarOptions } + : {}), + // The date axis is resolved once, in ListView — this face only + // forwards what the view declared, floored at 'name' + // (objectui#3129, objectui#6557). See `timelineViewOptions`. + timeline: legacyOptions.timeline ?? timelineViewOptions(viewDef), + map: legacyOptions.map ?? { + locationField: viewDef.map?.locationField, + titleField: viewDef.map?.titleField || 'name', + latitudeField: viewDef.map?.latitudeField, + longitudeField: viewDef.map?.longitudeField, + zoom: viewDef.map?.zoom, + center: viewDef.map?.center, + }, + // The gallery config the view DECLARED, title floored at + // 'name', and no invented cover field (objectui#7547). With no + // cover binding to forward, ListView's capability gate stops + // offering the Gallery toggle to a view that configured none. + // See `galleryViewOptions`. + gallery: legacyOptions.gallery ?? galleryViewOptions(viewDef), + // The gantt config the view DECLARED, title floored at 'name' — + // never an invented date field (`5f4514f7b`). With no date + // binding to forward, ListView's capability gate stops offering + // the Gantt toggle to a view that configured none, and a view + // forced onto the gantt renderer reaches its refusal screen. + // See `ganttViewOptions`. + gantt: legacyOptions.gantt ?? ganttViewOptions(viewDef), + tree: legacyOptions.tree ?? { + // Self-referencing tree-grid config (plugin-tree). Spread the + // full view-defined tree first so parentField/fields/ + // defaultExpandedDepth survive; labelField is floored at + // 'name'. parentField auto-detects when omitted. + // + // Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253): + // `viewDef` is `Record`, so the canonical rung + // below was an `any` property access and a misspelling was + // invisible. This is the half of objectui#7559 a declaration CAN + // close, on the one block that now has a declaration to close it + // with — it does NOT make a missing rung visible, which is what + // the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is + // for. + // + // ⚠️ The cast is repeated per rung rather than hoisted into a + // local: objectui#6557's convergence pin reads these seam lines + // out of this file and requires each to name `viewDef` itself. + // + // ⛔ No `titleField` rung (objectui#6152 round 14). `TreeViewConfig` + // is the spec's `ListView.tree` block, which refuses `titleField` + // by name (objectui#8841); the undeclared fallback that still + // read it is retired here and in `ListView` alike, so a stored + // `tree.titleField` labels nothing and the tree falls to 'name'. + ...((viewDef.tree as TreeViewConfig | undefined) || {}), + labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || 'name', + }, + // The chart block the view DECLARED, forwarded WHOLE — a + // pointer, not a copy of its key set (objectui#7823). + // + // This was a hand-listed projection of six keys (`chartType` / + // `xAxisField` / `yAxisFields` / `aggregation` / `series` / + // `config`): the PRE-ADR-0021 set, frozen. The whole ADR-0021 + // (objectstack-ai/objectstack#1890) authoring shape (`dataset` / `dimensions` / `values`) + // and the legacy `categoryField` / `valueField` spelling had no + // rung here, so an author who declared them reached `ListView` + // with the binding stripped. Once objectui#7544 gave + // `ListView.availableViews` a chart capability check, that gate + // was handed six `undefined` keys and correctly answered + // "nothing declared" about a view whose author declared + // everything — the Chart toggle stayed hidden, with no + // diagnostic, while the legacy `xAxisField` spelling (which the + // projection did carry) went on resolving. + // + // ⛔ NOT widened to nine keys. A hand-listed projection is a + // COPY of a schema's key set and copies rot silently: three more + // keys would buy this ADR's correctness and re-arm the identical + // trap for the next block key, with nothing to fire — `viewDef` + // is `Record`, so a missing rung is invisible to + // tsc (objectui#7559 owns that mechanism and disclaims the + // census this belongs to). + // + // Forwarding whole is safe because `ListView` never SPREADS this + // block — `resolveListChartBinding` and `case 'chart'` both read + // it BY NAME — so keys it does not consult are ignored exactly + // as they are for the `gantt` / `timeline` / `tree` blocks above, + // which are already relayed whole. Undeclared stays `undefined` + // rather than the old permanently-truthy husk, which is what + // keeps the gate from offering a chart nobody configured. + chart: legacyOptions.chart ?? viewDef.chart, + }, + }; + // objectui#10046 — equal content keeps the identity `ListView`'s fetch + // effect names; a real change (any differing value) still hands over + // the new bag and still refetches. Pinned by + // `ObjectView.hostRerenderRefetch-10046.test.tsx`. + if (isSameOptionsValue(heldListOptions.current, fullSchema.options)) { + fullSchema.options = heldListOptions.current as ListViewSchema['options']; + } else { + heldListOptions.current = fullSchema.options; + } + + return ( + { + if (record?.id != null) { + // useObjectActions.deleteRecord wraps execute() which + // already shows a confirmation dialog + success toast + // and triggers onRefresh on success. + // Pass the row so the confirmation names the + // record (objectui#11695). + actions.deleteRecord(String(record.id), record); + } + }} + onBulkDelete={(records: any[]) => { + const valid = records.filter((r: any) => r?.id != null); + if (valid.length === 0) return; + // `deleteRecords` confirms once for the whole batch, with the + // shared copy that counts it (objectui#11695), and the + // batch-aware delete handler handles refresh + toast. + actions.deleteRecords(valid); + }} + onRowClick={(record: any, event?: any) => { + handleRowClick(record, event); + }} + onSortChange={(sort: any) => { + // objectui#12418 — this user's sort, not the view's. + persistListSort(sort); + writeListUrlState({ sort }); + }} + // objectui#11860 — the URL only: nothing else stores the + // toolbar grouping, and the view's own stays as authored. + onGroupingChange={(grouping) => writeListUrlState({ grouping: grouping ?? null })} + onFilterChange={(filter: any) => { + // The USER's state (objectui#12418), never the view's + // stored body (objectui#4155) — the user's list state keeps + // the BUILDER's group verbatim, read back into + // `initialFilters`, which needs `conditions` (and the row + // ids) to rehydrate the toolbar. It follows the user to + // another browser, and the panel's own "Clear all" clears + // it: it writes an empty group through this same handler. + listPrefs.patchView(viewDef.id, { filters: filter }); + // objectui#11860 — and the URL, so the list is a link. + writeListUrlState({ filters: filter }); + }} + onSearchChange={(search: string) => { + writeListFilterState(listFilterKey, { search }); + writeListUrlState({ search }); + }} + onHiddenFieldsChange={persistHiddenFields} + // objectui#5144 (ruling E): session-only, writes nothing. + onInlineEditChange={keepInlineEditModeForTheSession} + onColumnStateChange={persistListColumnState} + userFilterSelections={initialUfSelections} + onUserFilterSelectionsChange={handleUserFilterSelectionsChange} + initialFilters={listSeed?.filters} + initialSearchTerm={listSeed?.search} + // objectui#12440 — the saved-view tabs, on the list's own + // toolbar row (see `viewTabBar`). `undefined` below `sm`. + toolbarLeading={viewTabBar ?? undefined} + // objectui#12606 — Setup's list opens without row numbers in + // the leading cell. A host prop, not a view key: a console + // posture, never metadata. + defaultRowNumbers={!inSetup} + // objectui#13330 — on a managed environment, the Users list + // marks a user who holds nothing beyond the baseline. A host + // prop the grid draws beside the row's title. + rowAdornment={managedUsers ? managedUserMarker : undefined} + dataSource={ds} + /> + ); + }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker]); + + return { renderListView }; +} diff --git a/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts b/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts index 211a10457dd..82dc539d393 100644 --- a/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts +++ b/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts @@ -43,7 +43,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const objectViewPath = path.join(here, 'ObjectView.tsx'); const widgetsPath = path.join(here, 'metadata-admin/widgets.tsx'); -const objectViewSrc = readFileSync(objectViewPath, 'utf8'); +const objectViewSrc = [objectViewPath, path.join(here, 'objectViewListRenderer.tsx')].map((file) => readFileSync(file, 'utf8')).join('\n'); const widgetsSrc = readFileSync(widgetsPath, 'utf8'); /** diff --git a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 8c00c5eab86..8dc5eff3f7e 100644 --- a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -2050,7 +2050,7 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w */ const LISTVIEW = 'packages/plugin-list/src/ListView.tsx'; -const APP_SHELL_OBJECT_VIEW = 'packages/app-shell/src/views/ObjectView.tsx'; +const APP_SHELL_OBJECT_VIEW = 'packages/app-shell/src/views/objectViewListRenderer.tsx'; /** A named view's one required member; every literal below is a delta on it. */ const BASE_VIEW = { label: 'All' } as const; diff --git a/ui/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts b/ui/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts index eaaba700568..03c86d39bdc 100644 --- a/ui/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts +++ b/ui/packages/types/src/__tests__/tree-view-config-readers-8253.test.ts @@ -77,7 +77,7 @@ const read = (rel: string) => readFileSync(join(REPO_ROOT, rel), 'utf8'); const RESOLVER = 'packages/plugin-tree/src/ObjectTree.tsx'; const VIEW_BRANCH = 'packages/plugin-view/src/ObjectView.tsx'; const LIST_BRANCH = 'packages/plugin-list/src/ListView.tsx'; -const CONSOLE_COMPOSITION = 'packages/app-shell/src/views/ObjectView.tsx'; +const CONSOLE_COMPOSITION = 'packages/app-shell/src/views/objectViewListRenderer.tsx'; /** * The declared keys, as a value — `it.each` needs one, and a failure named From ed4a000dab59b7862ac48dfa3e19c2405f28ce41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:29:47 +0000 Subject: [PATCH 11/11] refactor(app-shell): move ObjectView's view management handlers into a sibling hook (slice 11b) The view-config save and create handlers, the draft field update, view switching, the mobile view switcher registration and the view-tab CRUD callbacks move verbatim from ObjectViewInner into useObjectViewViewCrud (objectViewViewCrud.tsx). The panel and dialog state stays in the component. Six source-scan readers follow the moved handlers (path or file-list edits only). Claude-Session: https://claude.ai/code/session_01QciHBvFJXnhZqXmdqpDtpz Co-authored-by: Claude --- .../25992-objectview-view-crud-split.md | 12 + .../views/ObjectView.overlayPatchOnly.test.ts | 2 +- .../app-shell/src/views/ObjectView.tsx | 415 ++-------------- .../ObjectView.unboundAdapterCall.test.ts | 6 +- .../src/views/objectViewViewCrud.tsx | 459 ++++++++++++++++++ .../views/view-filter-fold.ratchet.test.ts | 2 +- .../views/writeRefusalCensus-11583.test.ts | 12 +- .../check-i18n-call-site-keys.test.ts | 2 +- 8 files changed, 526 insertions(+), 384 deletions(-) create mode 100644 .changeset/25992-objectview-view-crud-split.md create mode 100644 ui/packages/app-shell/src/views/objectViewViewCrud.tsx diff --git a/.changeset/25992-objectview-view-crud-split.md b/.changeset/25992-objectview-view-crud-split.md new file mode 100644 index 00000000000..8674f0cdc15 --- /dev/null +++ b/.changeset/25992-objectview-view-crud-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +refactor(app-shell): the Console `ObjectView`'s view management handlers move into a sibling hook + +Clause-②: no +Metadata-protocol: none + +The view-config panel's save and create handlers, the draft field update, view switching, the mobile view switcher registration and the view-tab CRUD callbacks (rename, delete, pin, set default, reorder, configure, add) move verbatim out of `ObjectViewInner` in `views/ObjectView.tsx` into `useObjectViewViewCrud` in `objectViewViewCrud.tsx`. The panel and dialog state stays in the component, which passes every input in and reads the returned handlers. No logic, name or rendered output changes, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts b/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts index ea2fd17d39e..3d7799724ce 100644 --- a/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts @@ -308,7 +308,7 @@ describe('objectui#5233 — what must NOT be narrowed', () => { */ describe('objectui#12504 census — the toolbar-to-view write family is retired', () => { const here = path.dirname(fileURLToPath(import.meta.url)); - const objectViewCode = stripComments(['ObjectView.tsx', 'objectViewListRenderer.tsx'].map((file) => readFileSync(path.join(here, file), 'utf8')).join('\n')); + const objectViewCode = stripComments(['ObjectView.tsx', 'objectViewListRenderer.tsx', 'objectViewViewCrud.tsx'].map((file) => readFileSync(path.join(here, file), 'utf8')).join('\n')); const dataSourceCode = stripComments( readFileSync(path.join(here, '..', '..', '..', 'types', 'src', 'data.ts'), 'utf8'), ); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 667ec7e9932..baf1b3de8d3 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -13,7 +13,6 @@ import { useMemo, useState, useCallback, useEffect, lazy, Suspense } from 'react import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom'; import { resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; import { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; -import { formatMetadataError } from '@object-ui/data-objectstack'; const ImportWizard = lazy(() => import('@object-ui/plugin-grid').then((m) => ({ default: m.ImportWizard })), ); @@ -29,21 +28,15 @@ import { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; import { useServedViewItems } from '../hooks/useServedViewItems.js'; -import { viewTabLabel } from './viewTabLabel.js'; import type { DataSource } from '@object-ui/types'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; import { dropPublishedViewList } from './metadata-admin/viewListDrop.js'; -import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence.js'; import { CreateViewDialog } from './CreateViewDialog.js'; import { usePreviewDrafts, - PREVIEW_QUERY_FLAG, - PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; -import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext.js'; -import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js'; import { isSetupAppSegment } from '../console/setupApp.js'; import { RecordDetailView } from './RecordDetailView.js'; import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; @@ -51,7 +44,7 @@ import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; import { resolveViewId } from '../utils/resolveViewId.js'; -import { defaultListViewId, viewRowId, isSavedViewId } from '../utils/viewIdentity.js'; +import { defaultListViewId, viewRowId } from '../utils/viewIdentity.js'; import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; @@ -65,15 +58,9 @@ import { } from './objectViewHelpers.js'; import { defaultListColumnsFromObject, - buildAddViewSpec, loadViewOverrides, buildViewTabs, - setDefaultViewPatches, - buildViewConfigSaveBody, } from './objectViewTabs.js'; -import { - dispatchViewPatches, -} from './objectViewOptions.js'; import { ObjectNotFoundState } from './objectViewChrome.js'; import { useObjectViewLiveData } from './objectViewLiveData.js'; import { useObjectViewIdentity } from './objectViewIdentity.js'; @@ -87,6 +74,7 @@ import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import { ObjectViewPageHeader } from './objectViewPageHeader.js'; import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; import { useObjectViewListRenderer } from './objectViewListRenderer.js'; +import { useObjectViewViewCrud } from './objectViewViewCrud.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling // `objectView*.ts` modules above. Every name this file exported stays @@ -224,151 +212,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // Draft state for view config edits — cached locally, saved on demand const [viewDraft, setViewDraft] = useState | null>(null); - - // objectui#12504 — no toolbar control writes the shared view. The sort and - // the column layout (objectui#12418), the density and the Hide Fields - // popover (objectui#12474) are the user's own list state (`listPrefs`), and - // the shared view is written only by an explicit save - // (`handleViewConfigSave`, below). The toolbar-to-view writer that lived - // here, `persistViewPatch`, retired with the adapter method it was the last - // caller of. ⛔ Do not bring one back: `ObjectView.overlayPatchOnly.test.ts` - // pins its absence. - - /* - * There is deliberately NO `persistViewFilter` here (objectui#4155). - * - * The list toolbar's filter panel is the USER's state — it belongs to the - * session and to that user's own list state (`listPrefs`, objectui#12418), - * not to the view's stored body. It used to persist: every `onFilterChange` the panel - * emitted was folded and written to the view overlay, so merely opening the - * panel and clicking `Add filter` wrote an incomplete `field equals ''` - * condition into `sys_metadata` — which then REPLACED the source-declared - * `filter` for every user of that view and emptied the list, unrecoverably - * from the panel itself. - * - * The threshold for writing a view overlay is an EXPLICIT save: - * `handleViewConfigSave` (the view config panel) and `handleSaveAsView` - * (`ObjectDataPage`). `ObjectDataPage` already made this choice for the - * sibling surface — "Deliberately NO onSortChange/onFilterChange - * persistence hooks: this surface never writes back to any saved view" - * (#2251) — and this surface was the outlier. - * - * `foldFilterGroupToSpecRules` (objectstack#5159) is still the one dialect - * for those explicit paths — the Studio inspector's `FilterBuilderField` - * folds through it. What is gone is the automatic write, not the fold. - */ - - /** - * The view-config panel's edit Save. Resolves whether the draft was staged, - * so the panel stays dirty, and announces no draft, on anything but `true` - * (objectui#11583). - */ - const handleViewConfigSave = useCallback(async (draft: Record): Promise => { - setViewDraft(draft); - setRefreshKey(k => k + 1); - - // ADR-0034: stage a per-item draft via the metadata seam; an explicit - // Publish (RuntimeDraftBar) promotes it + records a version. - const vid = draft.id; - if (!metadataClient || !vid) { - console.warn('[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.'); - return false; - } - try { - // `dataSource` + `objectName` let the seam drop this object's view - // cache keys (#4373) — the adapter owns which keys those are. - await persistRuntimeMetadata('view', vid, buildViewConfigSaveBody(objectName, draft), { - metadataClient, - dataSource, - objectName, - }); - return true; - } catch (err) { - console.error('[ViewConfigPanel] Failed to persist view config:', err); - // objectui#11583: a refused save is SAID, with the door's own - // message, as the Create View doors do (objectui#11578). - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - return false; - } - }, [metadataClient, dataSource, objectName, t]); - - /** - * Create a new view: the Create View dialog's door and the view-config - * panel's create mode. Resolves whether the view was saved, so the dialog - * closes only on `true` (objectui#11578). - */ - const handleViewCreate = useCallback(async (config: Record): Promise => { - try { - let createdId: string | undefined; - if (metadataClient) { - // ADR-0005 overlay path — write the full spec under a unique - // `name` via the metadata customization API instead of into - // the physical `sys_view` table (whose columns no longer - // accommodate the spec shape: arrays, nested objects, etc.). - // The spec comes from the builder "Save as view" also uses - // (`buildAddViewSpec` → `buildNewViewSpec`, objectui#11581): - // this door supplies only its default business columns. - const objectDef = objects?.find?.((o: any) => o.name === objectName); - const spec = buildAddViewSpec(config, objectDef); - // ADR-0034: a new view is created as an invisible per-item - // draft via the metadata seam; an explicit Publish promotes it. - // The auto-activation below stays here; the canonical ViewItem - // envelope + qualified-name identity live in the seam. - // - // #2767 P1: the qualified name `.` is used as BOTH - // the URL segment and `body.name`, so the sys_metadata row key, - // the ViewTabBar tab id, and the body identity all agree and the - // draft → read → publish loop resolves. - const env = viewEnvelope(objectName, spec, { - name: (config as any)?.name, - label: config.label, - }); - createdId = await createRuntimeMetadata('view', env.name, env, { - metadataClient, - // #4373: this flow is the second door into the same rows the - // adapter caches. Hand it the adapter (the `dataSource` prop - // IS the `ObjectStackAdapter`, via `AppContent`'s - // `useAdapter()`) so the seam can drop the object's view keys. - dataSource, - objectName, - }); - } - setShowViewConfigPanel(false); - setViewConfigPanelMode('edit'); - setRefreshKey(k => k + 1); - // Auto-activate the newly created view (Airtable parity), and close - // the main-path loop (#2767 P4): a fresh view is a DRAFT, invisible - // to a normal (published-only) read, so navigating there plainly - // would land on "view not found". Enter draft-preview mode - // (`?preview=draft`) instead — the DraftPreviewBar then lets the - // user verify and Publish in one click. If already in preview, the - // sticky provider keeps the flag, so no suffix is needed. - if (createdId) { - const previewSuffix = previewDrafts - ? '' - : `?${PREVIEW_QUERY_FLAG}=${PREVIEW_QUERY_VALUE}`; - if (viewId) { - navigate(`../${createdId}${previewSuffix}`, { relative: 'path' }); - } else { - navigate(`view/${createdId}${previewSuffix}`); - } - } - return true; - } catch (err) { - console.error('[ViewConfigPanel] Failed to create view:', err); - // objectui#11578: a refused save is SAID, with the door's own - // message (the field-anchored issues of a 422, or the refusal's - // text), and reported to the dialog as unsaved so it stays open. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - return false; - } - }, [dataSource, objectName, objects, navigate, viewId, metadataClient, previewDrafts, t]); // Record count tracking for footer const [recordCount, setRecordCount] = useState(undefined); @@ -786,221 +629,49 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setSearchParams, }); - /** Real-time draft field update — propagates each toggle/input change immediately */ - const handleViewUpdate = useCallback((field: string, value: any) => { - setViewDraft(prev => ({ - ...(prev || {}), - id: baseView?.id, - [field]: value, - })); - }, [baseView?.id]); - - const handleViewChange = (newViewId: string) => { - // The plugin ObjectView returns the view ID directly via onViewChange - const matchedView = views.find((v: any) => v.id === newViewId); - if (!matchedView) return; - // Auto-close the config panel only when actually switching to a - // different view. Same-view clicks (e.g., bubbling from the actions - // dropdown menu item) must not stomp on a freshly-opened panel. - if (matchedView.id !== activeViewId) { - setShowViewConfigPanel(false); - } - if (viewId) { - navigate(`../${matchedView.id}`, { relative: "path" }); - } else { - navigate(`view/${matchedView.id}`); - } - }; - - // Mobile view switcher — registers our view list with the AppHeader so - // the topbar can render a ` ▾` dropdown instead of the static - // page label. Desktop ignores this (ViewTabBar handles switching there). - const mobileViewSwitcherItems = useMemo(() => { - return (views || []).map((view: any) => { - const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS]; - return { - id: view.id, - label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), - icon: Icon ? : undefined, - }; - }); - }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]); - useMobileViewSwitcherRegistration({ - views: mobileViewSwitcherItems, - activeViewId: activeViewId ?? '', - onChange: handleViewChange, - enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId, + // objectstack#25992 slice 11b: the view-config save and create handlers, view switching, + // the mobile view switcher and the view-tab CRUD callbacks moved to `./objectViewViewCrud.tsx`. + const { + handleViewConfigSave, + handleViewCreate, + handleViewUpdate, + handleViewChange, + handleCreateView, + handleViewAction, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleReorderViews, + handleConfigView, + handleAddView, + } = useObjectViewViewCrud({ + dataSource, + objects, + navigate, + objectName, + viewId, + t, + language, + viewLabel, + servedViews, + metadataClient, + previewDrafts, + setShowViewConfigPanel, + setViewConfigPanelMode, + setShowCreateViewDialog, + setViewDraft, + objectDef, + unlabelledViewText, + setRefreshKey, + confirmHandler, + savedViews, + listPrefs, + views, + activeViewId, + baseView, }); - // ViewSwitcher callbacks — wired to both PluginObjectView instances - const handleCreateView = useCallback(() => { - setShowCreateViewDialog(true); - }, []); - - const handleViewAction = useCallback((actionType: string, viewType: string) => { - if (actionType === 'settings') { - // `type` is optional on a view entry until the grid-like default - // pass fills it in, so the predicate must tolerate its absence. - const matchedView = views.find((v: { id: string; type?: string }) => v.type === viewType); - if (matchedView) handleViewChange(matchedView.id); - setViewConfigPanelMode('edit'); - setShowViewConfigPanel(true); - } - }, [views, handleViewChange]); - - // ─── ViewTabBar CRUD callbacks (Phase 2) ──────────────────────────── - /** Returns true if the view is backed by a sys_view record (mutable). */ - const isSavedView = useCallback((vid: string) => { - return isSavedViewId(savedViews, vid); - }, [savedViews]); - - const handleRenameView = useCallback(async (vid: string, newName: string) => { - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - try { - // Metadata overlay path — `vid` is the view's `name` field. - if (typeof (dataSource as any)?.updateView === 'function') { - await (dataSource as any).updateView(objectName, vid, { label: newName }); - } - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to rename view:', err); - // objectui#12417: a refused rename is said with the door's own - // message, through the channel objectui#11583 gave the view saves - // and objectui#12398 gave Delete view. - toast.error(t('objectViewActions.renameFailed'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, objectName, isSavedView, t]); - - const handleDeleteView = useCallback(async (vid: string) => { - if (!dataSource) return; - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotDeleteMetaView')); - return; - } - const targetView = views.find((v: any) => v.id === vid); - const viewLabel = targetView?.label || vid; - const confirmed = await confirmHandler( - t('console.objectView.deleteViewConfirm', { name: viewLabel }), - { - title: t('console.objectView.deleteViewTitle'), - confirmText: t('console.objectView.delete'), - cancelText: t('console.objectView.cancel'), - }, - ); - if (!confirmed) return; - try { - if (typeof (dataSource as any)?.deleteView === 'function') { - await (dataSource as any).deleteView(objectName, vid); - } - // If we deleted the active view, fall back to the first remaining view. - if (vid === activeViewId) { - const fallback = views.find((v: any) => v.id !== vid); - if (fallback) navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined); - } - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to delete view:', err); - // objectui#12398: a refused delete is said with the door's own - // message, through the channel objectui#11583 gave the view saves - // (a 409 for a view two packages hold names its remedy there). - toast.error(t('objectViewActions.deleteFailed'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]); - - const handlePinView = useCallback(async (vid: string, pinned: boolean) => { - if (!dataSource) return; - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - try { - if (typeof (dataSource as any)?.updateView === 'function') { - await (dataSource as any).updateView(objectName, vid, { isPinned: pinned }); - } - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to pin view:', err); - // objectui#11583: a refused pin is said, with the door's message. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, objectName, isSavedView, t]); - - const handleSetDefaultView = useCallback(async (vid: string) => { - // `objectName` is narrowed here for the same reason the override effect - // above narrows it: the adapter keys its cache invalidation on a real - // object name, so passing `undefined` through would write to the server - // and then fail to invalidate what it just changed. - if (!dataSource || !objectName) return; - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - try { - // Clear `isDefault` on all other saved views, then set this one. - if (typeof (dataSource as any)?.updateView !== 'function') return; - await Promise.all( - dispatchViewPatches( - dataSource, - objectName, - setDefaultViewPatches(savedViews, vid), - ), - ); - setRefreshKey(k => k + 1); - } catch (err) { - console.error('[ViewTabBar] Failed to set default view:', err); - // objectui#11583: said like its siblings, with the door's message. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - } - }, [dataSource, objectName, savedViews, isSavedView, t]); - - /** - * A drag in Manage Views reorders the tabs FOR THIS USER (objectui#12418). - * - * It used to write `viewOrder:` to this browser and then PATCH a - * `sortOrder` onto every saved view's shared row, "so other sessions / - * users can pick up the order": one user's drag reordered the tabs for - * everyone, and under a walled posture the metadata write was refused. The - * order is now the user's preference (the `ui.list.` row, through - * `listPrefs`), and nothing here writes `/meta/view`. The authored order — - * the declared sequence, then a stored `sortOrder` — stays every user's - * default, and the view editor is where an author changes it. - */ - const handleReorderViews = useCallback((orderedIds: string[]) => { - listPrefs.setViewOrder(orderedIds); - }, [listPrefs.setViewOrder]); - - const handleConfigView = useCallback((vid: string) => { - // System (metadata-defined) views are read-only — opening the - // ViewConfigPanel against one would let the user save changes that - // never persist. - if (!isSavedView(vid)) { - toast.error(t('console.objectView.cannotEditMetaView')); - return; - } - if (vid !== activeViewId) handleViewChange(vid); - setViewConfigPanelMode('edit'); - setShowViewConfigPanel(true); - }, [activeViewId, handleViewChange, isSavedView, t]); - - const handleAddView = useCallback(() => { - setShowCreateViewDialog(true); - }, []); - // Current user — also used below for `{current_user_id}` filter-token // substitution. The schema-action handlers (toast/navigate/api/flow/server) // now live in the shared `useConsoleActionRuntime` hook (declared earlier). diff --git a/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts b/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts index e506feb6079..4af7e40e277 100644 --- a/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.unboundAdapterCall.test.ts @@ -188,10 +188,10 @@ describe('set as default (#4463)', () => { describe('the detach-then-call pattern stays gone (ratchet)', () => { it('ObjectView.tsx never assigns an adapter method to a local', () => { - const source = readFileSync( - fileURLToPath(new URL('./ObjectView.tsx', import.meta.url)), + const source = ['./ObjectView.tsx', './objectViewViewCrud.tsx'].map((file) => readFileSync( + fileURLToPath(new URL(file, import.meta.url)), 'utf8', - ); + )).join('\n'); // Comments are stripped FIRST, and that is not a detail: the fix's own // docblock quotes the broken line verbatim to explain it, so a scan over the diff --git a/ui/packages/app-shell/src/views/objectViewViewCrud.tsx b/ui/packages/app-shell/src/views/objectViewViewCrud.tsx new file mode 100644 index 00000000000..43b07abccb5 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewViewCrud.tsx @@ -0,0 +1,459 @@ +/** + * The view management handlers of the Console `ObjectView` (`./ObjectView.tsx`): + * the view-config panel's save and create doors, the draft field update, view + * switching, the mobile view switcher registration, and the view-tab CRUD + * callbacks (rename, delete, pin, set default, reorder, configure, add), moved + * out of `ObjectViewInner` verbatim (objectstack#25992, slice 11b). The panel and + * dialog state stays in the component; every input is passed in, and the + * component reads the returned handlers. + */ + +import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'; +import type { useNavigate } from 'react-router-dom'; +import { formatMetadataError } from '@object-ui/data-objectstack'; +import { toast } from 'sonner'; +import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; +import { PREVIEW_QUERY_FLAG, PREVIEW_QUERY_VALUE } from '../preview/PreviewModeContext.js'; +import type { usePreviewDrafts } from '../preview/PreviewModeContext.js'; +import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext.js'; +import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext.js'; +import { isSavedViewId } from '../utils/viewIdentity.js'; +import type { useServedViewItems } from '../hooks/useServedViewItems.js'; +import type { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; +import type { useMetadataClient } from './metadata-admin/useMetadata.js'; +import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence.js'; +import { VIEW_TYPE_ICONS } from './objectViewHelpers.js'; +import { buildAddViewSpec, setDefaultViewPatches, buildViewConfigSaveBody } from './objectViewTabs.js'; +import type { buildViewTabs } from './objectViewTabs.js'; +import { dispatchViewPatches } from './objectViewOptions.js'; +import { viewTabLabel } from './viewTabLabel.js'; +import type { ConsoleObjectViewProps } from './ObjectView.js'; + +export function useObjectViewViewCrud({ + dataSource, + objects, + navigate, + objectName, + viewId, + t, + language, + viewLabel, + servedViews, + metadataClient, + previewDrafts, + setShowViewConfigPanel, + setViewConfigPanelMode, + setShowCreateViewDialog, + setViewDraft, + objectDef, + unlabelledViewText, + setRefreshKey, + confirmHandler, + savedViews, + listPrefs, + views, + activeViewId, + baseView, +}: { + dataSource: ConsoleObjectViewProps['dataSource']; + objects: ConsoleObjectViewProps['objects']; + navigate: ReturnType; + objectName: string | undefined; + viewId: string | undefined; + t: ReturnType['t']; + language: ReturnType['language']; + viewLabel: ReturnType['viewLabel']; + servedViews: ReturnType; + metadataClient: ReturnType; + previewDrafts: ReturnType; + setShowViewConfigPanel: Dispatch>; + setViewConfigPanelMode: Dispatch>; + setShowCreateViewDialog: Dispatch>; + setViewDraft: Dispatch[number]> | null>>; + objectDef: ConsoleObjectViewProps['objects'][number]; + unlabelledViewText: string; + setRefreshKey: Dispatch>; + confirmHandler: ReturnType['confirmHandler']; + savedViews: Parameters[0]['savedViews']; + listPrefs: ReturnType; + views: ReturnType; + activeViewId: string | undefined; + baseView: ReturnType[number]; +}) { + // objectui#12504 — no toolbar control writes the shared view. The sort and + // the column layout (objectui#12418), the density and the Hide Fields + // popover (objectui#12474) are the user's own list state (`listPrefs`), and + // the shared view is written only by an explicit save + // (`handleViewConfigSave`, below). The toolbar-to-view writer that lived + // here, `persistViewPatch`, retired with the adapter method it was the last + // caller of. ⛔ Do not bring one back: `ObjectView.overlayPatchOnly.test.ts` + // pins its absence. + + /* + * There is deliberately NO `persistViewFilter` here (objectui#4155). + * + * The list toolbar's filter panel is the USER's state — it belongs to the + * session and to that user's own list state (`listPrefs`, objectui#12418), + * not to the view's stored body. It used to persist: every `onFilterChange` the panel + * emitted was folded and written to the view overlay, so merely opening the + * panel and clicking `Add filter` wrote an incomplete `field equals ''` + * condition into `sys_metadata` — which then REPLACED the source-declared + * `filter` for every user of that view and emptied the list, unrecoverably + * from the panel itself. + * + * The threshold for writing a view overlay is an EXPLICIT save: + * `handleViewConfigSave` (the view config panel) and `handleSaveAsView` + * (`ObjectDataPage`). `ObjectDataPage` already made this choice for the + * sibling surface — "Deliberately NO onSortChange/onFilterChange + * persistence hooks: this surface never writes back to any saved view" + * (#2251) — and this surface was the outlier. + * + * `foldFilterGroupToSpecRules` (objectstack#5159) is still the one dialect + * for those explicit paths — the Studio inspector's `FilterBuilderField` + * folds through it. What is gone is the automatic write, not the fold. + */ + + /** + * The view-config panel's edit Save. Resolves whether the draft was staged, + * so the panel stays dirty, and announces no draft, on anything but `true` + * (objectui#11583). + */ + const handleViewConfigSave = useCallback(async (draft: Record): Promise => { + setViewDraft(draft); + setRefreshKey(k => k + 1); + + // ADR-0034: stage a per-item draft via the metadata seam; an explicit + // Publish (RuntimeDraftBar) promotes it + records a version. + const vid = draft.id; + if (!metadataClient || !vid) { + console.warn('[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.'); + return false; + } + try { + // `dataSource` + `objectName` let the seam drop this object's view + // cache keys (#4373) — the adapter owns which keys those are. + await persistRuntimeMetadata('view', vid, buildViewConfigSaveBody(objectName, draft), { + metadataClient, + dataSource, + objectName, + }); + return true; + } catch (err) { + console.error('[ViewConfigPanel] Failed to persist view config:', err); + // objectui#11583: a refused save is SAID, with the door's own + // message, as the Create View doors do (objectui#11578). + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + return false; + } + }, [metadataClient, dataSource, objectName, t]); + + /** + * Create a new view: the Create View dialog's door and the view-config + * panel's create mode. Resolves whether the view was saved, so the dialog + * closes only on `true` (objectui#11578). + */ + const handleViewCreate = useCallback(async (config: Record): Promise => { + try { + let createdId: string | undefined; + if (metadataClient) { + // ADR-0005 overlay path — write the full spec under a unique + // `name` via the metadata customization API instead of into + // the physical `sys_view` table (whose columns no longer + // accommodate the spec shape: arrays, nested objects, etc.). + // The spec comes from the builder "Save as view" also uses + // (`buildAddViewSpec` → `buildNewViewSpec`, objectui#11581): + // this door supplies only its default business columns. + const objectDef = objects?.find?.((o: any) => o.name === objectName); + const spec = buildAddViewSpec(config, objectDef); + // ADR-0034: a new view is created as an invisible per-item + // draft via the metadata seam; an explicit Publish promotes it. + // The auto-activation below stays here; the canonical ViewItem + // envelope + qualified-name identity live in the seam. + // + // #2767 P1: the qualified name `.` is used as BOTH + // the URL segment and `body.name`, so the sys_metadata row key, + // the ViewTabBar tab id, and the body identity all agree and the + // draft → read → publish loop resolves. + const env = viewEnvelope(objectName, spec, { + name: (config as any)?.name, + label: config.label, + }); + createdId = await createRuntimeMetadata('view', env.name, env, { + metadataClient, + // #4373: this flow is the second door into the same rows the + // adapter caches. Hand it the adapter (the `dataSource` prop + // IS the `ObjectStackAdapter`, via `AppContent`'s + // `useAdapter()`) so the seam can drop the object's view keys. + dataSource, + objectName, + }); + } + setShowViewConfigPanel(false); + setViewConfigPanelMode('edit'); + setRefreshKey(k => k + 1); + // Auto-activate the newly created view (Airtable parity), and close + // the main-path loop (#2767 P4): a fresh view is a DRAFT, invisible + // to a normal (published-only) read, so navigating there plainly + // would land on "view not found". Enter draft-preview mode + // (`?preview=draft`) instead — the DraftPreviewBar then lets the + // user verify and Publish in one click. If already in preview, the + // sticky provider keeps the flag, so no suffix is needed. + if (createdId) { + const previewSuffix = previewDrafts + ? '' + : `?${PREVIEW_QUERY_FLAG}=${PREVIEW_QUERY_VALUE}`; + if (viewId) { + navigate(`../${createdId}${previewSuffix}`, { relative: 'path' }); + } else { + navigate(`view/${createdId}${previewSuffix}`); + } + } + return true; + } catch (err) { + console.error('[ViewConfigPanel] Failed to create view:', err); + // objectui#11578: a refused save is SAID, with the door's own + // message (the field-anchored issues of a 422, or the refusal's + // text), and reported to the dialog as unsaved so it stays open. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + return false; + } + }, [dataSource, objectName, objects, navigate, viewId, metadataClient, previewDrafts, t]); + + /** Real-time draft field update — propagates each toggle/input change immediately */ + const handleViewUpdate = useCallback((field: string, value: any) => { + setViewDraft(prev => ({ + ...(prev || {}), + id: baseView?.id, + [field]: value, + })); + }, [baseView?.id]); + + const handleViewChange = (newViewId: string) => { + // The plugin ObjectView returns the view ID directly via onViewChange + const matchedView = views.find((v: any) => v.id === newViewId); + if (!matchedView) return; + // Auto-close the config panel only when actually switching to a + // different view. Same-view clicks (e.g., bubbling from the actions + // dropdown menu item) must not stomp on a freshly-opened panel. + if (matchedView.id !== activeViewId) { + setShowViewConfigPanel(false); + } + if (viewId) { + navigate(`../${matchedView.id}`, { relative: "path" }); + } else { + navigate(`view/${matchedView.id}`); + } + }; + + // Mobile view switcher — registers our view list with the AppHeader so + // the topbar can render a ` ▾` dropdown instead of the static + // page label. Desktop ignores this (ViewTabBar handles switching there). + const mobileViewSwitcherItems = useMemo(() => { + return (views || []).map((view: any) => { + const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS]; + return { + id: view.id, + label: viewTabLabel(view, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + icon: Icon ? : undefined, + }; + }); + }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]); + useMobileViewSwitcherRegistration({ + views: mobileViewSwitcherItems, + activeViewId: activeViewId ?? '', + onChange: handleViewChange, + enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId, + }); + + // ViewSwitcher callbacks — wired to both PluginObjectView instances + const handleCreateView = useCallback(() => { + setShowCreateViewDialog(true); + }, []); + + const handleViewAction = useCallback((actionType: string, viewType: string) => { + if (actionType === 'settings') { + // `type` is optional on a view entry until the grid-like default + // pass fills it in, so the predicate must tolerate its absence. + const matchedView = views.find((v: { id: string; type?: string }) => v.type === viewType); + if (matchedView) handleViewChange(matchedView.id); + setViewConfigPanelMode('edit'); + setShowViewConfigPanel(true); + } + }, [views, handleViewChange]); + + // ─── ViewTabBar CRUD callbacks (Phase 2) ──────────────────────────── + /** Returns true if the view is backed by a sys_view record (mutable). */ + const isSavedView = useCallback((vid: string) => { + return isSavedViewId(savedViews, vid); + }, [savedViews]); + + const handleRenameView = useCallback(async (vid: string, newName: string) => { + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + try { + // Metadata overlay path — `vid` is the view's `name` field. + if (typeof (dataSource as any)?.updateView === 'function') { + await (dataSource as any).updateView(objectName, vid, { label: newName }); + } + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to rename view:', err); + // objectui#12417: a refused rename is said with the door's own + // message, through the channel objectui#11583 gave the view saves + // and objectui#12398 gave Delete view. + toast.error(t('objectViewActions.renameFailed'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, objectName, isSavedView, t]); + + const handleDeleteView = useCallback(async (vid: string) => { + if (!dataSource) return; + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotDeleteMetaView')); + return; + } + const targetView = views.find((v: any) => v.id === vid); + const viewLabel = targetView?.label || vid; + const confirmed = await confirmHandler( + t('console.objectView.deleteViewConfirm', { name: viewLabel }), + { + title: t('console.objectView.deleteViewTitle'), + confirmText: t('console.objectView.delete'), + cancelText: t('console.objectView.cancel'), + }, + ); + if (!confirmed) return; + try { + if (typeof (dataSource as any)?.deleteView === 'function') { + await (dataSource as any).deleteView(objectName, vid); + } + // If we deleted the active view, fall back to the first remaining view. + if (vid === activeViewId) { + const fallback = views.find((v: any) => v.id !== vid); + if (fallback) navigate(viewId ? `../${fallback.id}` : `view/${fallback.id}`, viewId ? { relative: 'path' } : undefined); + } + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to delete view:', err); + // objectui#12398: a refused delete is said with the door's own + // message, through the channel objectui#11583 gave the view saves + // (a 409 for a view two packages hold names its remedy there). + toast.error(t('objectViewActions.deleteFailed'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, isSavedView, activeViewId, views, viewId, navigate, t, confirmHandler]); + + const handlePinView = useCallback(async (vid: string, pinned: boolean) => { + if (!dataSource) return; + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + try { + if (typeof (dataSource as any)?.updateView === 'function') { + await (dataSource as any).updateView(objectName, vid, { isPinned: pinned }); + } + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to pin view:', err); + // objectui#11583: a refused pin is said, with the door's message. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, objectName, isSavedView, t]); + + const handleSetDefaultView = useCallback(async (vid: string) => { + // `objectName` is narrowed here for the same reason the override effect + // above narrows it: the adapter keys its cache invalidation on a real + // object name, so passing `undefined` through would write to the server + // and then fail to invalidate what it just changed. + if (!dataSource || !objectName) return; + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + try { + // Clear `isDefault` on all other saved views, then set this one. + if (typeof (dataSource as any)?.updateView !== 'function') return; + await Promise.all( + dispatchViewPatches( + dataSource, + objectName, + setDefaultViewPatches(savedViews, vid), + ), + ); + setRefreshKey(k => k + 1); + } catch (err) { + console.error('[ViewTabBar] Failed to set default view:', err); + // objectui#11583: said like its siblings, with the door's message. + toast.error(t('form.saveError'), { + description: formatMetadataError(err), + classNames: { description: 'whitespace-pre-line' }, + }); + } + }, [dataSource, objectName, savedViews, isSavedView, t]); + + /** + * A drag in Manage Views reorders the tabs FOR THIS USER (objectui#12418). + * + * It used to write `viewOrder:` to this browser and then PATCH a + * `sortOrder` onto every saved view's shared row, "so other sessions / + * users can pick up the order": one user's drag reordered the tabs for + * everyone, and under a walled posture the metadata write was refused. The + * order is now the user's preference (the `ui.list.` row, through + * `listPrefs`), and nothing here writes `/meta/view`. The authored order — + * the declared sequence, then a stored `sortOrder` — stays every user's + * default, and the view editor is where an author changes it. + */ + const handleReorderViews = useCallback((orderedIds: string[]) => { + listPrefs.setViewOrder(orderedIds); + }, [listPrefs.setViewOrder]); + + const handleConfigView = useCallback((vid: string) => { + // System (metadata-defined) views are read-only — opening the + // ViewConfigPanel against one would let the user save changes that + // never persist. + if (!isSavedView(vid)) { + toast.error(t('console.objectView.cannotEditMetaView')); + return; + } + if (vid !== activeViewId) handleViewChange(vid); + setViewConfigPanelMode('edit'); + setShowViewConfigPanel(true); + }, [activeViewId, handleViewChange, isSavedView, t]); + + const handleAddView = useCallback(() => { + setShowCreateViewDialog(true); + }, []); + + return { + handleViewConfigSave, + handleViewCreate, + handleViewUpdate, + handleViewChange, + handleCreateView, + handleViewAction, + handleRenameView, + handleDeleteView, + handlePinView, + handleSetDefaultView, + handleReorderViews, + handleConfigView, + handleAddView, + }; +} diff --git a/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts b/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts index 82dc539d393..27061f0fae0 100644 --- a/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts +++ b/ui/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts @@ -43,7 +43,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const objectViewPath = path.join(here, 'ObjectView.tsx'); const widgetsPath = path.join(here, 'metadata-admin/widgets.tsx'); -const objectViewSrc = [objectViewPath, path.join(here, 'objectViewListRenderer.tsx')].map((file) => readFileSync(file, 'utf8')).join('\n'); +const objectViewSrc = [objectViewPath, path.join(here, 'objectViewListRenderer.tsx'), path.join(here, 'objectViewViewCrud.tsx')].map((file) => readFileSync(file, 'utf8')).join('\n'); const widgetsSrc = readFileSync(widgetsPath, 'utf8'); /** diff --git a/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts b/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts index 175181f9384..4fee778d117 100644 --- a/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts +++ b/ui/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts @@ -88,32 +88,32 @@ const LEDGER: Row[] = [ reason: 'Issues one `updateView` per patch and hands every promise back; set-as-default and reorder await them and answer for the refusal.', }, { - file: 'ObjectView.tsx', symbol: 'handleViewConfigSave', callee: 'persistRuntimeMetadata', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleViewConfigSave', callee: 'persistRuntimeMetadata', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'The view-config panel\'s edit Save: a toast with the door\'s message, and the panel is told the save failed, so it stays dirty (objectui#11583).', }, { - file: 'ObjectView.tsx', symbol: 'handleViewCreate', callee: 'createRuntimeMetadata', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleViewCreate', callee: 'createRuntimeMetadata', calls: 1, kind: 'surfaces', pin: 'ObjectView.createViewRefusal-11578.test.tsx', reason: 'Create View: a toast with the door\'s message, and the dialog stays open (objectui#11578).', }, { - file: 'ObjectView.tsx', symbol: 'handleRenameView', callee: 'updateView', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleRenameView', callee: 'updateView', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Rename: the translated `objectViewActions.renameFailed` title, with the door\'s message as the description (objectui#12417).', }, { - file: 'ObjectView.tsx', symbol: 'handleDeleteView', callee: 'deleteView', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleDeleteView', callee: 'deleteView', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Delete: the translated `objectViewActions.deleteFailed` title, with the door\'s message as the description (objectui#12398).', }, { - file: 'ObjectView.tsx', symbol: 'handlePinView', callee: 'updateView', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handlePinView', callee: 'updateView', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Pin and unpin: a toast with the door\'s message (objectui#11583).', }, { - file: 'ObjectView.tsx', symbol: 'handleSetDefaultView', callee: 'dispatchViewPatches', calls: 1, + file: 'objectViewViewCrud.tsx', symbol: 'handleSetDefaultView', callee: 'dispatchViewPatches', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', reason: 'Set as default: a toast with the door\'s message; it was a bare untranslated literal with no description until the objectui#11583 patch round.', }, diff --git a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts index c2d0e4b0333..55eda0eec34 100644 --- a/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts +++ b/ui/scripts/__tests__/check-i18n-call-site-keys.test.ts @@ -1088,7 +1088,7 @@ export const A = (name: string, label: string, n: number) => { "t('marketplace.detail.reseedLocalSuccess', { inserted, updated })", ], [ - 'packages/app-shell/src/views/ObjectView.tsx', + 'packages/app-shell/src/views/objectViewViewCrud.tsx', // Tight on purpose: the file still holds a legitimate *computed* // `defaultValue` ending in the same sentence (line ~1657), and a marker // loose enough to catch that one would fail for the wrong reason.