From 3d8aec56daed3c3a7c99550833ecca89f6870b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:40:32 +0000 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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]); +}