From 1e85a2d97a901c240c8804d0ed22abaabbe70aa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:31:27 +0000 Subject: [PATCH 1/2] fix(app-shell,plugin-list,plugin-view): no invented gantt date field names, so the refusal screen becomes reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view carrying no `gantt:` block used to have a complete-looking date axis synthesized for it: all three faces floored `startDateField` at 'start_date' and `endDateField` at 'end_date' — field names no view had written and most objects do not carry. `ObjectGantt.getGanttConfig` takes its flat branch as soon as BOTH date props are present, so the fabricated pair short-circuited the renderer's own refusal screen, which has existed all along and was simply unreachable from every route. The same fabrication answered ADR-0047's capability gate in `ListView.availableViews`, so the Gantt toggle was live on every object view in the product. The premise was MEASURED before anything was deleted, because #7029's mechanic is only correct where a refusal path exists and that had never been established for this renderer: on the unmodified tree, `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`. All three faces now forward only what the author declared. The app-shell inline branch becomes `ganttViewOptions`, the sibling of `calendarViewOptions` and `timelineViewOptions`. Also corrects the objectui#3129 note at the top of `app-shell/ObjectView.tsx`, which certified the gantt branch below it as already using the safe two-rung shape. It did not. The note now states each sibling branch as measured, and says explicitly which fabrication REMAINS — the timeline 'created_at' floor at the two plugin faces — rather than staying silent about it. The #7062 scan control that anchored on this face's 'start_date' floor is re-expressed rather than deleted: a machinery control on the permanent 'name' floor, plus a same-class control on the gallery branch's surviving 'image' floor, with a hand-off note for whoever retires that one. Out of scope, left in place: `progressField` / `dependenciesField`, and the timeline 'created_at' posture conflict. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .../7070-no-invented-gantt-date-fields.md | 53 +++++ .../ObjectView.calendarBinding-7029.test.tsx | 46 +++- .../ObjectView.ganttBinding-7070.test.tsx | 161 +++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 89 ++++++-- ...ectGantt.unconfiguredRefusal-7070.test.tsx | 184 +++++++++++++++ packages/plugin-list/src/ListView.tsx | 27 ++- .../ListView.gantt-binding-7070.test.tsx | 216 ++++++++++++++++++ packages/plugin-view/src/ObjectView.tsx | 22 +- .../ObjectView.ganttBinding-7070.test.tsx | 117 ++++++++++ 9 files changed, 887 insertions(+), 28 deletions(-) create mode 100644 .changeset/7070-no-invented-gantt-date-fields.md create mode 100644 packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx create mode 100644 packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx create mode 100644 packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx diff --git a/.changeset/7070-no-invented-gantt-date-fields.md b/.changeset/7070-no-invented-gantt-date-fields.md new file mode 100644 index 0000000000..aa768c6020 --- /dev/null +++ b/.changeset/7070-no-invented-gantt-date-fields.md @@ -0,0 +1,53 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-list': minor +'@object-ui/plugin-view': minor +--- + +Gantt views no longer render on invented date field names (objectui#7070). + +The half PR #7062 fenced out and reported separately. A view that carried no +`gantt:` block used to have a complete-looking date axis synthesized for it: +all three faces floored `startDateField` at `'start_date'` and `endDateField` +at `'end_date'` — field names no view had written and most objects do not +carry. + +`ObjectGantt.getGanttConfig` takes its flat branch as soon as BOTH date props +are present, so the fabricated pair short-circuited the renderer's own refusal +screen — "Gantt configuration required. Please specify startDateField, +endDateField, and titleField." — which existed all along and was simply +unreachable from every route. The same fabrication answered ADR-0047's +capability gate in `ListView.availableViews`, so the Gantt toggle was live on +every object view in the product. + +⚠️ The premise was MEASURED before anything was deleted, because #7029's +mechanic is only correct where a refusal path exists and that had never been +established for this renderer: on the unmodified tree, `ObjectGantt` REFUSES an +absent binding — it does not render empty, and it does not throw. + +Three faces were fabricating, on two independent routes to the same renderer: + +- `app-shell/src/views/ObjectView.tsx` — the console object page. The inline + branch becomes `ganttViewOptions`, the sibling of `calendarViewOptions` and + `timelineViewOptions`: the declared block spread whole, title floored at + `'name'`, no date field invented. +- `plugin-list/src/ListView.tsx` — the render branch AND the capability gate. +- `plugin-view/src/ObjectView.tsx` — `generateViewSchema`, the authored + `object-view` element route, which bypasses `ListView` entirely. + +**What changes for an author.** A view that declared no gantt configuration is +no longer offered the Gantt toggle, and one forced onto the renderer reaches +the refusal screen instead of a plausible, fully wrong chart. A view that +declared a binding is unaffected — the declared block is forwarded exactly as +before, every spec key included. + +Also corrected: the objectui#3129 note at the top of `app-shell/ObjectView.tsx` +certified the gantt branch below it as already using the safe two-rung shape. +It did not. The note now states each sibling branch as measured, and says +explicitly which fabrication REMAINS — the timeline `'created_at'` floor at the +two plugin faces, which objectui#7070 routes to a ruling rather than settling +per-face. + +Deliberately out of scope, and left in place: `progressField` / `dependenciesField` +(not date axes, different absent-value semantics) and the timeline `'created_at'` +posture conflict. diff --git a/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx b/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx index ba138321f7..7db8e06ecd 100644 --- a/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx +++ b/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx @@ -130,12 +130,44 @@ describe('no invented calendar field name survives in the source (objectui#7029) expect(CODE.filter((l) => /calendar\?\.titleField \|\| 'name'/.test(l))).toEqual([]); }); - it('CONTROL: the scan can still see a literal that IS there', () => { - // Without this the two cases above are green on any tree where the filter - // simply matches nothing — the failure mode that made the first spelling of - // this scan a phantom check. The gantt branch still carries its own - // `'start_date'` floor (same class, separately reported, deliberately NOT - // touched by this card), so it is the honest positive control. - expect(CODE.filter((l) => l.includes("'start_date'")).length).toBeGreaterThan(0); + /** + * ⭐ THE POSITIVE CONTROL — read this before changing either case below. + * + * Without a control the two cases above are green on any tree where the filter + * simply matches nothing: the failure mode that made the first spelling of + * this scan a phantom check. This control was originally a single case + * anchored on the gantt branch's own `'start_date'` floor ("same class, + * separately reported, deliberately NOT touched by this card"). + * + * objectui#7070 is that separate report, and fixing it deleted the anchor — + * `'start_date'` is now absent from this face's CODE, so the original control + * would have gone red. ⛔ It was NOT simply removed: a scan whose last live + * control is dropped becomes a test that passes over nothing, which is the + * exact class this whole card family is about. It is re-expressed as TWO + * controls that answer two different questions: + */ + + it('CONTROL (machinery): the scan can see a literal that IS there', () => { + // Can this filter return a non-empty result at all? Anchored on `'name'`, + // which is DELIBERATELY permanent here — the display-name floor the + // objectui#3129 note above the seams describes as what stays. It is not a + // fabricated field name, so no future card of this family retires it, and + // this control cannot go red as a side effect of the next fix. + expect(CODE.filter((l) => l.includes("'name'")).length).toBeGreaterThan(0); + }); + + it('CONTROL (same class): the scan still sees a REMAINING fabricated field name', () => { + // Can this filter see the specific thing it exists to hunt — a one-rung + // `|| 'literal'` field-name floor? Anchored on the gallery branch's + // `imageField: … || 'image'`, which is the same class, is still there, and + // is out of scope for both objectui#7029 and objectui#7070. + // + // ⚠️ TO WHOEVER RETIRES `'image'`: this going red is the mechanic working, + // not a broken test. RE-ANCHOR it onto whatever fabrication legitimately + // remains in this file — do not delete it, and do not weaken it to the + // machinery control above. If nothing of this class remains anywhere in this + // face, say so in the PR body and convert this case into the assertion that + // NONE remains, so the scan keeps making a claim about the tree. + expect(CODE.filter((l) => l.includes("'image'")).length).toBeGreaterThan(0); }); }); diff --git a/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx b/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx new file mode 100644 index 0000000000..f68fb7f1bb --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.ganttBinding-7070.test.tsx @@ -0,0 +1,161 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7070 — the object page must not invent a GANTT date binding. + * + * The sibling of `ObjectView.calendarBinding-7029` next door, and the half PR + * #7062 fenced out and reported separately. This face floored + * `startDateField` at `'start_date'` and `endDateField` at `'end_date'` for + * EVERY object view, declared or not. Downstream that is indistinguishable + * from a real binding: `ObjectGantt.getGanttConfig` takes its flat branch as + * soon as BOTH date props are present, so the fabrication made the renderer's + * own "Gantt configuration required" screen unreachable from this route, and it + * answered ADR-0047's capability gate (`options.gantt.startDateField`) so the + * Gantt toggle was live on every object view in the product. + * + * ⚠️ THE PREMISE WAS MEASURED BEFORE THE DELETION. #7029's mechanic — delete the + * literal, let the renderer's own refusal answer — is only correct where a + * refusal path exists, and that had never been established for gantt. It was, + * on the unmodified tree, before this file's fix was written: `ObjectGantt` + * REFUSES an absent binding (it does not render empty, and does not throw). The + * seam is pinned in `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070`. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `startDateField: viewDef.gantt?.startDateField || 'start_date'` and + * its `endDateField` twin, and the "invents NO date field" cases below go RED + * (they read the fabricated names) while every declared-config CONTROL stays + * GREEN in either world — the fabricated value is only ever observable when the + * view declared nothing. That asymmetry is the point: a fix that emitted an + * empty config for EVERY view would also pass a refusal-only test. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ganttViewOptions } from './ObjectView'; + +describe('ganttViewOptions — the object page forwards, it does not invent (objectui#7070)', () => { + it('invents NO date field for a view that declares no gantt config', () => { + // THE DEFECT. This used to return `{ startDateField: 'start_date', + // endDateField: 'end_date', titleField: 'name' }` — a complete-looking axis + // for a view that configured nothing, which is what short-circuited the + // renderer's refusal screen and lit the Gantt toggle everywhere. + expect(ganttViewOptions({})).toEqual({ titleField: 'name' }); + expect(ganttViewOptions(undefined)).toEqual({ titleField: 'name' }); + expect(ganttViewOptions({ label: 'All', columns: ['name'] })).toEqual({ titleField: 'name' }); + }); + + it('invents no date field for a view whose neighbouring blocks ARE declared', () => { + // A view bound for kanban/calendar must not acquire a gantt axis by + // proximity — the Gantt toggle it would light up has nothing behind it. + const out = ganttViewOptions({ + kanban: { groupByField: 'stage' }, + calendar: { startDateField: 'start_date' }, + }); + expect(out).not.toHaveProperty('startDateField'); + expect(out).not.toHaveProperty('endDateField'); + }); + + it('invents no date field for an EMPTY gantt block', () => { + // The half-written declaration: `allowedVisualizations: ['gantt']` with + // nothing under `gantt:`. It must stay half-written all the way down. + const out = ganttViewOptions({ gantt: {} }); + expect(out).not.toHaveProperty('startDateField'); + expect(out).not.toHaveProperty('endDateField'); + }); + + it('CONTROL: forwards a fully declared block verbatim — every spec key survives', () => { + // A bare whitelist here once dropped every field past `colorField` and + // flattened the chart, so the spread is load-bearing, not incidental. + const out = ganttViewOptions({ + gantt: { + startDateField: 'planned_start', + endDateField: 'planned_end', + titleField: 'subject', + parentField: 'parent', + typeField: 'node_type', + baselineStartField: 'bl_start', + groupByField: 'owner', + tooltipFields: [{ field: 'owner' }], + }, + }); + expect(out).toMatchObject({ + startDateField: 'planned_start', + endDateField: 'planned_end', + titleField: 'subject', + parentField: 'parent', + typeField: 'node_type', + baselineStartField: 'bl_start', + groupByField: 'owner', + tooltipFields: [{ field: 'owner' }], + }); + }); + + it('CONTROL: a HALF-declared axis keeps its declared rung and only that', () => { + // One date field is not a gantt: `getGanttConfig`'s flat branch needs BOTH, + // so this shape reaches the refusal screen rather than rendering on a name + // nobody wrote. + const out = ganttViewOptions({ gantt: { startDateField: 'planned_start' } }); + expect(out.startDateField).toBe('planned_start'); + expect(out).not.toHaveProperty('endDateField'); + }); + + it('keeps the `name` title floor — a display default is not a date axis', () => { + // Deliberately NOT removed by this card, and the same rung + // `timelineViewOptions` carries. `ObjectGantt` floors the title itself too. + expect(ganttViewOptions({}).titleField).toBe('name'); + expect(ganttViewOptions({ gantt: { titleField: 'subject' } }).titleField).toBe('subject'); + }); +}); + +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', + ); + + /** + * Executable lines only. The prose above this file's own seams names + * `'start_date'` / `'end_date'` repeatedly — that is the record of what was + * deleted, and a scan that counted it would be red on a correct tree. Same + * filter, and same reason, as the objectui#7029 scan next door. + */ + const CODE = SOURCE.split('\n').filter((l) => !/^\s*(\*|\/\*|\/\/)/.test(l)); + + it("the fabricated 'start_date' / 'end_date' floors are gone from this face's CODE", () => { + // A structural tripwire, not a restatement of the cases above: these are + // what a future copy-paste from a sibling branch would reintroduce, and they + // are invisible to a behavioural test on any object that happens to carry + // real `start_date` / `end_date` fields — which is exactly how the defect + // survived (hotcrm's `crm_leave_request` carries both). + expect(CODE.filter((l) => l.includes("'start_date'"))).toEqual([]); + expect(CODE.filter((l) => l.includes("'end_date'"))).toEqual([]); + }); + + it('CONTROL: the scan can still see a literal that IS there', () => { + // Without this the case above is green on any tree where the filter simply + // matches nothing. `'image'` is the gallery branch's `imageField … || + // 'image'` floor — the same one-rung class, still present, out of scope for + // this card. ⚠️ If you retire it, RE-ANCHOR this control onto whatever + // fabrication legitimately remains; do not delete it. (The twin of this + // control in `ObjectView.calendarBinding-7029.test.tsx` carries the same + // instruction, and explains why in full.) + expect(CODE.filter((l) => l.includes("'image'")).length).toBeGreaterThan(0); + }); + + it('CONTROL: the scan reads CODE, not the prose that records the deletion', () => { + // The filter's own correctness. The seam comments above `ganttViewOptions` + // name both deleted literals; if the filter ever stopped stripping comment + // lines, the case above would go red on a CORRECT tree and invite someone to + // weaken it. + expect(SOURCE.includes("`'start_date'`")).toBe(true); + expect(CODE.filter((l) => l.includes("'start_date'"))).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 49cd1f4c35..52e0ae8790 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -148,14 +148,39 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any { * 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'` — the - * same two-rung shape the calendar and gantt branches below already use. An + * 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 (objectui#7070). 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 objectui#7070; that is what makes `ObjectGantt`'s + * own "Gantt configuration required" screen reachable from this route. + * - ⛔ STILL FABRICATING, and deliberately NOT fixed by objectui#7070: the + * TIMELINE axis at the two SIBLING FACES. `plugin-list/ListView.tsx` and + * `plugin-view/ObjectView.tsx` both floor `startDateField` at `'created_at'` + * — the very literal objectui#3129 retired HERE. `ListView` carries it as a + * stated decision ("`created_at` stays the last resort for a view that + * declares no date axis anywhere"), so the two faces hold contradictory + * DOCUMENTED postures on one literal. objectui#7070 routes that to a single + * ruling instead of settling it per-face. Until it is answered: this note + * describes the timeline axis at THIS face only, and says nothing about the + * other two. + * * Exported for the regression suite. */ export function timelineViewOptions(viewDef: any): Record { @@ -213,6 +238,45 @@ export function calendarViewOptions(viewDef: any): Record | und return { ...declared }; } +/** + * The `options.gantt` config this page hands to `ListView`. + * + * objectui#7070 — 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 record-detail URL this list surface builds — one route shape, one place. * @@ -2289,20 +2353,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an coverField: viewDef.gallery?.coverField || viewDef.gallery?.imageField, titleField: viewDef.gallery?.titleField || 'name', }, - gantt: { - // Spread the full view-defined gantt config first so the - // renderer's extended fields (parentField/typeField for the - // summary→step hierarchy, baseline*, groupByField, - // resourceView/assignee*, tooltipFields, quickFilters, …) - // survive; then layer the three required defaults last so an - // omitted source value still falls back. (Mirrors the gallery - // branch above — a bare whitelist here was dropping every - // field past colorField and flattening the chart.) - ...(viewDef.gantt || {}), - startDateField: viewDef.gantt?.startDateField || 'start_date', - endDateField: viewDef.gantt?.endDateField || 'end_date', - titleField: viewDef.gantt?.titleField || 'name', - }, + // The gantt config the view DECLARED, title floored at 'name' — + // never an invented date field (objectui#7070). 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: ganttViewOptions(viewDef), tree: { // Self-referencing tree-grid config (plugin-tree). Spread the // full view-defined tree first so parentField/fields/ diff --git a/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx b/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx new file mode 100644 index 0000000000..ea8c8468d8 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx @@ -0,0 +1,184 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7070 — the refusal screen this component has always carried becomes + * REACHABLE from the object-page route. + * + * The sibling of objectui#7029's `ObjectCalendar.unconfiguredRefusal-7029`, and + * written for the same reason: #7029's mechanic (delete the fabricated literal + * upstream, let the renderer's own refusal answer) is only correct where a + * refusal path EXISTS. #7070 was filed precisely because that premise had never + * been measured for the gantt renderer — "deleting these literals blind, on the + * strength of #7062's success, is the specific mistake this card is written to + * prevent". This file is that measurement, kept as the regression. + * + * MEASURED (before any upstream edit, on `2c3cd1b75`): `getGanttConfig` returns + * `null` for a schema carrying neither a `gantt` block nor BOTH flat date props, + * and the early return at `ObjectGantt.tsx` renders "Gantt configuration + * required. Please specify startDateField, endDateField, and titleField." So the + * answer is REFUSE — not render-empty, not throw. Nothing in this component + * changed for #7070; what changed is upstream (`app-shell/ObjectView`, + * `plugin-list/ListView`, `plugin-view/ObjectView` stopped fabricating + * `'start_date'` / `'end_date'`), so the props an unconfigured view now delivers + * carry no binding at all and this screen is what the author sees. + * + * ⛔ The refusal screen itself is deliberately NOT redesigned by this card — + * these cases read its existing copy verbatim. + * + * Both directions are pinned, because a fix that refused EVERY view would also + * pass a refusal-only test: the CONTROL cases assert a declared gantt still + * renders its tasks, on its own declared fields, unchanged. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { ObjectGantt } from './ObjectGantt'; + +// Stand-in for the timeline canvas: the point of every case here is WHICH of +// the two screens is reached, so the chart only has to be identifiable and to +// name the tasks it was handed. +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
+ {tasks.map((t: any) => ( +
{t.title}
+ ))} +
+ ), +})); + +afterEach(cleanup); + +const REFUSAL = /Gantt configuration required/i; + +const ROWS = [ + { id: 'r1', name: 'Ada onboarding', start_date: '2024-01-01', end_date: '2024-01-05' }, + { id: 'r2', name: 'Grace onboarding', start_date: '2024-01-06', end_date: '2024-01-10' }, +]; + +const objectDef = { + name: 'crm_leave_request', + fields: { + id: { type: 'text' }, + name: { type: 'text' }, + start_date: { type: 'date' }, + end_date: { type: 'date' }, + }, +}; + +const makeDataSource = () => + ({ + find: vi.fn().mockResolvedValue({ data: ROWS }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(objectDef), + }) as any; + +describe('ObjectGantt — an unconfigured view reaches the refusal screen (objectui#7070)', () => { + it('REFUSES the props a view with NO gantt block now produces', async () => { + // Exactly what the fixed faces emit for a view that declared nothing: the + // flat binding props are simply absent. Before this card the same view + // arrived carrying `startDateField: 'start_date'` / `endDateField: + // 'end_date'`, so this early return was unreachable from all three routes. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + // …and it is a REFUSAL, not a chart that merely looks empty. This is the + // distinction #7070 asked to be measured before deleting anything. + expect(screen.queryByTestId('gantt-view')).toBeNull(); + expect(screen.queryByTestId('gantt-task')).toBeNull(); + }); + + it('REFUSES a HALF-declared axis — one date field is not a gantt', async () => { + // `getGanttConfig`'s flat branch is taken only when BOTH date fields are + // present; a partial spelling falls through to `null`. Pinned because the + // fixed faces now emit exactly this shape for a view that declared one rung. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + expect(screen.queryByTestId('gantt-view')).toBeNull(); + }); + + it('refuses even when the out-of-scope `progress`/`dependencies` floors are still handed in', async () => { + // ⛔ objectui#7070 deliberately leaves `progressField: … || 'progress'` and + // `dependenciesField: … || 'dependencies'` in place on the two plugin faces + // (a different flavour — not date axes, different absent-value semantics). + // This case is the measurement that leaving them does NOT keep the refusal + // unreachable: `getGanttConfig` gates on the two DATE fields only, so the + // surviving pair cannot resurrect a config on its own. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + expect(screen.queryByTestId('gantt-view')).toBeNull(); + }); + + it('CONTROL: a declared flat binding renders its tasks, unaffected', async () => { + // Without this case a fix that refused EVERYTHING would look identical to + // the fix that was ruled. + render( + , + ); + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + expect(screen.getByText('Ada onboarding')).toBeTruthy(); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: the nested spec `gantt` block still configures the renderer', async () => { + render( + , + ); + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + expect(screen.getByText('Grace onboarding')).toBeTruthy(); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index b70d5ee437..cdb912faf8 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2314,7 +2314,27 @@ export const ListView = React.forwardRef(({ ...(schema.timeline?.scale ? { scale: schema.timeline.scale } : {}), }; } - case 'gantt': + case 'gantt': { + // objectui#7070: only ever restate a binding the view actually DECLARED + // — the same correction objectui#7029 made to the calendar branch above, + // which fenced this one out and reported it separately. These two keys + // used to be floored at 'start_date' / 'end_date', field names no view + // had written and most objects do not carry. `ObjectGantt.getGanttConfig` + // takes its flat branch as soon as BOTH date props are present, so a + // fabricated pair short-circuited the renderer's own refusal screen and + // produced a plausible, fully wrong chart instead. MEASURED first, since + // #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 in + // `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`. + // + // ⛔ `progressField` / `dependenciesField` keep their floors here: they + // are NOT date axes, their absent-value semantics differ, and #7070 + // scoped them out deliberately. Leaving them cannot resurrect a config — + // `getGanttConfig` gates on the two date fields alone (pinned in the same + // file), so the refusal stays reachable with the pair still present. + const startDateField = schema.gantt?.startDateField || schema.options?.gantt?.startDateField; + const endDateField = schema.gantt?.endDateField || schema.options?.gantt?.endDateField; return { type: 'object-gantt', ...baseProps, @@ -2322,14 +2342,15 @@ export const ListView = React.forwardRef(({ // read, write}` (composite endpoint) must reach ObjectGantt, whose // getDataConfig prefers schema.data over the objectName fallback. ...(schema.data ? { data: schema.data } : {}), - startDateField: schema.gantt?.startDateField || schema.options?.gantt?.startDateField || 'start_date', - endDateField: schema.gantt?.endDateField || schema.options?.gantt?.endDateField || 'end_date', + ...(startDateField ? { startDateField } : {}), + ...(endDateField ? { endDateField } : {}), progressField: schema.gantt?.progressField || schema.options?.gantt?.progressField || 'progress', dependenciesField: schema.gantt?.dependenciesField || schema.options?.gantt?.dependenciesField || 'dependencies', ...(schema.gantt?.titleField ? { titleField: schema.gantt.titleField } : {}), ...(schema.options?.gantt || {}), ...(schema.gantt || {}), }; + } case 'map': { // Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_KEYS`. // `schema.options.map` is an untyped bag; a raw spread here forwarded diff --git a/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx b/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx new file mode 100644 index 0000000000..aa8d1b7fb2 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx @@ -0,0 +1,216 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7070 — what ListView hands the gantt renderer, and which views it + * offers the Gantt toggle to. + * + * The sibling of `ListView.calendar-binding-7029` next door. `ObjectView` was + * one half of this face's problem; this branch is the other, and it is the half + * that decides whether the fix is observable at all — with the object page fixed + * and this branch untouched, `'start_date'` / `'end_date'` simply take over as + * the fabricated names one layer down and the renderer still never sees an + * absent binding. + * + * Two read-sites are pinned because they answer two different questions and both + * used to be answered by the fabrication: + * + * - the RENDER branch — which fields does the gantt lay its bars on? + * - the CAPABILITY gate (`availableViews`) — may this view offer Gantt at all? + * ADR-0047: a visualization is offered only when its binding resolves. The + * gate reads `schema.gantt?.startDateField || schema.options?.gantt?.…`, and + * the object page put a fabricated `options.gantt.startDateField` on every + * view in the product, so the toggle was always live. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `|| 'start_date'` / `|| 'end_date'` on the two lines this card + * deletes and the "invents NO binding" case goes RED (the spy reads the + * fabricated names) while every declared-config case here stays GREEN. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { render, waitFor, screen, cleanup, fireEvent } from '@testing-library/react'; +import { ListView } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const rows = [ + { id: '1', name: 'Ada onboarding', start_date: '2099-09-01', end_date: '2099-09-03' }, + { id: '2', name: 'Grace onboarding', start_date: '2099-10-01', end_date: '2099-10-02' }, +]; + +const objectDef = { + name: 'crm_leave_request', + label: 'Leave Request', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name' }, + start_date: { name: 'start_date', type: 'date', label: 'Start Date' }, + end_date: { name: 'end_date', type: 'date', label: 'End Date' }, + }, +}; + +let captured: Array> = []; + +ComponentRegistry.register( + 'object-gantt', + (props: Record) => { + captured.push(props); + return
; + }, + { namespace: 'test', label: 'Gantt spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => rows), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + }) as any; + +const BASE = { + type: 'list-view', + objectName: 'crm_leave_request', + viewType: 'gantt', + columns: ['name'], +} as const; + +/** Mount ListView on `schema` and return the props the gantt was given. */ +async function ganttProps(schema: Record) { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(captured.length).toBeGreaterThan(0)); + return captured[captured.length - 1].schema; +} + +const queryViewOption = (name: string) => + screen.queryByRole('tab', { name }) ?? screen.queryByRole('button', { name }); + +beforeEach(() => { + captured = []; +}); +afterEach(cleanup); + +describe('ListView gantt branch — only ever restates a DECLARED binding (objectui#7070)', () => { + it('invents NO date binding for a gantt view that declares no config', async () => { + // THE DEFECT, at this layer. `startDateField` used to read 'start_date' and + // `endDateField` 'end_date' here — names this view never wrote. Absent + // bindings are what let `getGanttConfig` return null downstream, which is + // the only route to the renderer's refusal screen. + const props = await ganttProps({ ...BASE }); + expect(props.startDateField).toBeUndefined(); + expect(props.endDateField).toBeUndefined(); + }); + + it('invents no date binding when the view carries an EMPTY gantt block', async () => { + const props = await ganttProps({ ...BASE, gantt: {} }); + expect(props.startDateField).toBeUndefined(); + expect(props.endDateField).toBeUndefined(); + }); + + it('invents no date binding when only the OTHER gantt keys are declared', async () => { + // ⛔ Scoped out of #7070 on purpose: `progressField` / `dependenciesField` + // keep their floors — not date axes, different absent-value semantics. This + // case is the measurement that keeping them does not resurrect an axis: + // `getGanttConfig` gates on the two DATE fields alone, so the refusal stays + // reachable with the pair still being handed down. + const props = await ganttProps({ ...BASE, gantt: { progressField: 'pct' } }); + expect(props.startDateField).toBeUndefined(); + expect(props.endDateField).toBeUndefined(); + expect(props.progressField).toBe('pct'); + expect(props.dependenciesField).toBe('dependencies'); + }); + + it('CONTROL: forwards the spec-canonical `gantt` block unchanged', async () => { + const props = await ganttProps({ + ...BASE, + gantt: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }, + }); + expect(props.startDateField).toBe('start_date'); + expect(props.endDateField).toBe('end_date'); + expect(props.titleField).toBe('name'); + }); + + it('CONTROL: forwards the legacy `options.gantt` nesting unchanged', async () => { + // The nesting app-shell's object page emits. A correctly configured view + // renders exactly as it did before this card. + const props = await ganttProps({ + ...BASE, + options: { gantt: { startDateField: 'start_date', endDateField: 'end_date', colorField: 'status' } }, + }); + expect(props.startDateField).toBe('start_date'); + expect(props.endDateField).toBe('end_date'); + expect(props.colorField).toBe('status'); + }); + + it('CONTROL: a partially declared axis keeps its declared half and only that', async () => { + const props = await ganttProps({ ...BASE, gantt: { startDateField: 'start_date' } }); + expect(props.startDateField).toBe('start_date'); + expect(props.endDateField).toBeUndefined(); + }); +}); + +describe('ListView capability gate — the Gantt toggle follows the binding (objectui#7070)', () => { + const GRID = { ...BASE, viewType: 'grid' } as const; + + /** + * The switcher has two forms — an inline segmented control (role="tab") and a + * collapsed dropdown (plain buttons behind a trigger) — so the trigger has to + * be opened before querying, and both roles have to be accepted. Querying + * without opening returns null for BOTH worlds, which would make the negative + * case below pass while measuring nothing. Copied from the objectui#7029 file + * next door, where that was measured on its first run. + */ + const mountSwitcher = async (schema: Record) => { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const trigger = screen.queryByTestId('view-switcher-dropdown'); + if (trigger) fireEvent.click(trigger); + }; + + it('CONTROL: offers Gantt to a view that declared a binding', async () => { + // The positive control comes FIRST: it proves this harness can see the + // option at all, so the negative case below is a measurement rather than a + // query that never had anything to find. + await mountSwitcher({ ...GRID, gantt: { startDateField: 'start_date', endDateField: 'end_date' } }); + await waitFor(() => expect(queryViewOption('Gantt')).toBeInTheDocument()); + }); + + it('CONTROL: offers Gantt for the legacy `options.gantt` nesting too', async () => { + // The shape the object page emits for a view that DID declare a gantt block + // — the half of the gate that must keep working after the fabrication left. + await mountSwitcher({ + ...GRID, + options: { gantt: { startDateField: 'start_date', endDateField: 'end_date' } }, + }); + await waitFor(() => expect(queryViewOption('Gantt')).toBeInTheDocument()); + }); + + it('does NOT offer Gantt to a view that declared no gantt binding', async () => { + // ADR-0047: offered only when the binding resolves. This is the second + // premise objectui#7070 asked to be measured rather than assumed — that the + // capability gate drops the toggle "for free" once the fabrication is gone, + // as it did for calendar. It does: the gate reads the same declared config + // this card stopped inventing, so no second mechanism was needed. + await mountSwitcher({ ...GRID, appearance: { allowedVisualizations: ['grid', 'gantt'] } }); + expect(queryViewOption('Gantt')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 48582ab47a..eea2e7fa36 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1356,11 +1356,29 @@ export const ObjectView: React.FC = ({ ...(viewOptions.timeline || {}), }; case 'gantt': + // objectui#7070: only ever restate a binding the view actually DECLARED + // — the same correction objectui#7029 made to the calendar branch above. + // `startDateField` / `endDateField` used to be floored at 'start_date' / + // 'end_date', field names no view had written and most objects do not + // carry. `ObjectGantt.getGanttConfig` takes its flat branch as soon as + // BOTH date props are present, so a fabricated pair short-circuited the + // renderer's own refusal screen. ObjectGantt REFUSES an absent binding + // (measured — it does not render empty and does not throw); pinned in + // `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`. + // + // ⛔ `progressField` / `dependenciesField` keep their floors: not date + // axes, different absent-value semantics, scoped out of #7070. They + // cannot resurrect a config on their own — `getGanttConfig` gates on the + // two date fields alone. return { type: 'object-gantt', ...baseProps, - startDateField: viewOptions.gantt?.startDateField || 'start_date', - endDateField: viewOptions.gantt?.endDateField || 'end_date', + ...(viewOptions.gantt?.startDateField + ? { startDateField: viewOptions.gantt.startDateField } + : {}), + ...(viewOptions.gantt?.endDateField + ? { endDateField: viewOptions.gantt.endDateField } + : {}), progressField: viewOptions.gantt?.progressField || 'progress', dependenciesField: viewOptions.gantt?.dependenciesField || 'dependencies', ...(viewOptions.gantt || {}), diff --git a/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx new file mode 100644 index 0000000000..ab0170177f --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx @@ -0,0 +1,117 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7070 — the third face. `generateViewSchema`'s gantt branch floored + * `startDateField` / `endDateField` at `'start_date'` / `'end_date'` for every + * view, declared or not, exactly as the app-shell and ListView faces did. + * + * The sibling of `ObjectView.calendarBinding-7029` next door. Fixing two of the + * three faces would leave this one fabricating the same names one route over — + * which is the shape of the original defect, where objectui#3129 fixed the + * timeline axis at app-shell alone and the two plugin faces kept inventing. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `|| 'start_date'` / `|| 'end_date'` and the "invents NO binding" case + * goes RED while every CONTROL stays GREEN in either world. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +/** Every schema the view hands to SchemaRenderer, in order. */ +const rendered: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', () => ({ ObjectGrid: () =>
})); +vi.mock('@object-ui/plugin-form', () => ({ ObjectForm: () =>
})); + +async function renderGanttView(view: Record) { + rendered.length = 0; + const ds: any = { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'crm_leave_request', fields: {} }), + }; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; +} + +describe('ObjectView.generateViewSchema — gantt restates only a DECLARED binding (objectui#7070)', () => { + it('invents NO date binding for a gantt view that declares no config', async () => { + const schema = await renderGanttView({}); + expect(schema.type).toBe('object-gantt'); + // Both used to be fabricated here. Absent bindings are the only route to + // `ObjectGantt`'s refusal screen — measured to exist before this deletion, + // and pinned in `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070`. + expect(schema.startDateField).toBeUndefined(); + expect(schema.endDateField).toBeUndefined(); + }); + + it('invents no date binding for an EMPTY gantt block', async () => { + // ⚠️ `gantt` sits at the VIEW's top level here, not under `options`: + // `viewOptions` is `currentNamedViewConfig?.options || activeView`, and a + // raw `views` entry takes the `activeView` leg. Written as `{ options: + // { gantt } }` first, this case and the two CONTROLs below all read + // `undefined` — the CONTROLs went red and exposed it, which is exactly the + // job a declared-config control exists to do. Written the wrong way, THIS + // case would have passed while measuring nothing at all. + const schema = await renderGanttView({ gantt: {} }); + expect(schema.startDateField).toBeUndefined(); + expect(schema.endDateField).toBeUndefined(); + }); + + it('keeps the out-of-scope `progress` / `dependencies` floors (objectui#7070 scope)', async () => { + // ⛔ Not date axes, different absent-value semantics, deliberately left. The + // point of pinning them is that the SCOPE is visible: if a later card + // retires them, this case is where it declares that it did. + const schema = await renderGanttView({}); + expect(schema.progressField).toBe('progress'); + expect(schema.dependenciesField).toBe('dependencies'); + }); + + it('CONTROL: forwards a declared gantt block unchanged', async () => { + const schema = await renderGanttView({ + gantt: { startDateField: 'planned_start', endDateField: 'planned_end', titleField: 'subject' }, + }); + expect(schema.type).toBe('object-gantt'); + expect(schema.startDateField).toBe('planned_start'); + expect(schema.endDateField).toBe('planned_end'); + expect(schema.titleField).toBe('subject'); + }); + + it('CONTROL: a partially declared axis keeps its declared half and only that', async () => { + const schema = await renderGanttView({ gantt: { startDateField: 'planned_start' } }); + expect(schema.startDateField).toBe('planned_start'); + expect(schema.endDateField).toBeUndefined(); + }); +}); From be5648cc8fca691921f5f392b20d901113806418 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 01:01:40 +0000 Subject: [PATCH 2/2] test(plugin-list): measure the ADR-0047 gate on the exact bag the fixed object page emits The negative capability-gate case supplied no `options.gantt` at all, so it answered "no binding declared" rather than the question objectui#7070 actually asked: does the gate drop the Gantt toggle once the OBJECT PAGE stops fabricating? The fabrication the gate used to read came from `options.gantt`, which after the fix is a bag that still EXISTS (`{ titleField: 'name' }`) but carries no axis. That shape is now pinned directly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .../ListView.gantt-binding-7070.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx b/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx index aa8d1b7fb2..a9bbbd0baf 100644 --- a/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx @@ -204,6 +204,24 @@ describe('ListView capability gate — the Gantt toggle follows the binding (obj await waitFor(() => expect(queryViewOption('Gantt')).toBeInTheDocument()); }); + it('does NOT offer Gantt for the exact bag the fixed object page now emits', async () => { + // ⭐ THE SECOND PREMISE, measured rather than assumed. #7070 asked whether + // ADR-0047's gate drops the Gantt toggle "for free" once the fabrication is + // gone, the way it did for calendar. The gate reads + // `schema.gantt?.startDateField || schema.options?.gantt?.startDateField`, + // and the fabrication it used to read came from the OBJECT PAGE, which put + // `options.gantt.startDateField: 'start_date'` on every view in the product. + // `{ gantt: { titleField: 'name' } }` is precisely what `ganttViewOptions` + // now emits for a view that declared nothing — a bag that still EXISTS but + // carries no axis. The answer is yes: no second mechanism was needed. + await mountSwitcher({ + ...GRID, + appearance: { allowedVisualizations: ['grid', 'gantt'] }, + options: { gantt: { titleField: 'name' } }, + }); + expect(queryViewOption('Gantt')).not.toBeInTheDocument(); + }); + it('does NOT offer Gantt to a view that declared no gantt binding', async () => { // ADR-0047: offered only when the binding resolves. This is the second // premise objectui#7070 asked to be measured rather than assumed — that the