From 8aab5f307378192a1bb4df94b9c016d67280954c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:28:53 +0000 Subject: [PATCH 1/3] fix(app-shell,plugin-list): stop inventing calendar field names for views that declared none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view carrying no `calendar:` block used to have a complete-looking calendar config synthesized for it: `ObjectView` fabricated `startDateField: 'due_date'` and `titleField: 'name'`, and `ListView`'s calendar branch floored the same two bindings at `'start_date'` / `'end_date'` one layer down. `ObjectCalendar` decides whether it has a usable configuration by asking whether a start-date binding is PRESENT, so the fabrication short-circuited its own refusal screen — which has existed all along and was simply unreachable from this route. Both faces now forward only what the author declared. With no binding to forward, the capability gate stops offering the Calendar toggle to views that configured none, and a view forced onto the renderer reaches the refusal screen instead of a plausible, fully wrong one. Ruled on objectstack#13748 (director batch #19, option A). The spec half — cross-field validation of a half-written declaration — is objectstack#13817. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../ObjectView.calendarBinding-7029.test.tsx | 141 +++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 59 +++++- ...Calendar.unconfiguredRefusal-7029.test.tsx | 138 ++++++++++++ packages/plugin-list/src/ListView.tsx | 27 ++- .../ListView.calendar-binding-7029.test.tsx | 199 ++++++++++++++++++ 5 files changed, 550 insertions(+), 14 deletions(-) create mode 100644 packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx create mode 100644 packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx diff --git a/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx b/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx new file mode 100644 index 0000000000..ba138321f7 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx @@ -0,0 +1,141 @@ +/** + * 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#7029 — the object page must not invent a calendar field binding. + * + * Ruled on objectstack#13748 (director batch #19, option A). This face used to + * emit `startDateField: 'due_date'` and `titleField: 'name'` into + * `options.calendar` for EVERY object view, declared or not. Downstream that is + * indistinguishable from a real binding, and it is what made + * `ObjectCalendar`'s own refusal screen ("Calendar configuration required. + * Please specify startDateField and titleField.") 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): nine records piled + * onto today's cell under titles resolved through the display-name chain. + * + * The exact shape objectui#3129 already gave the timeline face one branch up + * (`timelineViewOptions`), and ADR-0085 gave the kanban lane ("never invents a + * field the object doesn't have"), and `InterfaceListPage.defaultCalendarFromObject` + * has always had (a binding, or `undefined` — never a guess). + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `startDateField: viewDef.calendar?.startDateField || 'due_date'` / + * `titleField: … || 'name'` and the "invents NO field names" cases below go RED + * (they read the fabricated names), while the "forwards what the author + * declared" cases stay GREEN in either world — the fabricated value is only + * ever observable when the view declared nothing. That asymmetry is the point: + * a fix that refused EVERY view would also pass a refusal-only test, so the + * declared-config cases are carried here as the control. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { calendarViewOptions } from './ObjectView'; + +describe('calendarViewOptions — the object page forwards, it does not invent (objectui#7029)', () => { + it('invents NO calendar config for a view that declares none', () => { + // THE DEFECT. This used to be + // `{ startDateField: 'due_date', titleField: 'name', … }` — a complete-looking + // config for a view that configured nothing, which is precisely what + // short-circuited the renderer's refusal screen. + expect(calendarViewOptions({})).toBeUndefined(); + expect(calendarViewOptions({ label: 'All', columns: ['name'] })).toBeUndefined(); + expect(calendarViewOptions(undefined)).toBeUndefined(); + }); + + it('invents no config for a view whose neighbouring blocks ARE declared', () => { + // A view bound for kanban/timeline must not acquire a calendar binding by + // proximity — the calendar toggle it would light up has nothing behind it. + expect( + calendarViewOptions({ kanban: { groupByField: 'stage' }, timeline: { startDateField: 'start_date' } }), + ).toBeUndefined(); + }); + + it('CONTROL: forwards a fully declared block verbatim — every spec key survives', () => { + const out = calendarViewOptions({ + calendar: { + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'subject', + colorField: 'status', + allDayField: 'all_day', + defaultView: 'week', + }, + }); + expect(out).toEqual({ + startDateField: 'start_date', + endDateField: 'end_date', + titleField: 'subject', + colorField: 'status', + allDayField: 'all_day', + defaultView: 'week', + }); + }); + + it('CONTROL: a key the old whitelist would have dropped survives the spread', () => { + // The gallery and gantt branches next door each had to learn this the hard + // way: a bare whitelist silently drops every spec key it does not name. + const out = calendarViewOptions({ calendar: { startDateField: 'start_date', scale: 'month' } }); + expect(out).toMatchObject({ startDateField: 'start_date', scale: 'month' }); + }); + + it('forwards a HALF-declared block as-is — the missing rung stays missing', () => { + // `calendar: { titleField }` with no date binding is the half-written + // declaration objectstack#13817 closes in the spec. At runtime it must stay + // half-written all the way down, so the renderer refuses instead of + // rendering on a name nobody wrote. + const out = calendarViewOptions({ calendar: { titleField: 'subject' } }); + expect(out).toEqual({ titleField: 'subject' }); + expect(out).not.toHaveProperty('startDateField'); + }); + + it('ignores a non-object `calendar` value rather than forwarding garbage', () => { + expect(calendarViewOptions({ calendar: true })).toBeUndefined(); + expect(calendarViewOptions({ calendar: 'start_date' })).toBeUndefined(); + }); +}); + +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', + ); + + /** + * Executable lines only. The prose above this file's own seams names + * `'due_date'` repeatedly — that is the record of what was deleted, and a + * scan that counted it would be red on a correct tree (measured: it was, on + * the first run of this file). + */ + const CODE = SOURCE.split('\n').filter((l) => !/^\s*(\*|\/\*|\/\/)/.test(l)); + + it("the fabricated 'due_date' binding is gone from this face's CODE entirely", () => { + // A structural tripwire, not a restatement of the cases above: the literal + // is what a future copy-paste from a sibling branch would reintroduce, and + // it is invisible to a behavioural test on any object that happens to carry + // a real `due_date` field. + expect(CODE.filter((l) => l.includes("'due_date'"))).toEqual([]); + }); + + it('the calendar seam no longer floors its title at a name the view never wrote', () => { + 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); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 29936e6d52..49cd1f4c35 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record { }; } +/** + * 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 record-detail URL this list surface builds — one route shape, one place. * @@ -2037,6 +2079,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // controls should be (#2219). warnSuppressedListNav(objectDef.name, viewDef.id || viewDef.name || '', viewDef as any, listSchema as any); + // objectui#7029: present only when the view actually declared one. + const calendarOptions = calendarViewOptions(viewDef); + const fullSchema: ListViewSchema = { ...listSchema, // The active view's display label (same string the ViewTabBar @@ -2216,14 +2261,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an titleField: viewDef.kanban?.titleField || 'name', cardFields: viewDef.kanban?.columns, }, - calendar: { - startDateField: viewDef.calendar?.startDateField || 'due_date', - endDateField: viewDef.calendar?.endDateField, - titleField: viewDef.calendar?.titleField || 'name', - colorField: viewDef.calendar?.colorField, - allDayField: viewDef.calendar?.allDayField, - defaultView: viewDef.calendar?.defaultView, - }, + // The calendar config the view DECLARED, or no calendar key at + // all — never an invented field name (objectui#7029). With the + // key absent, ListView's capability gate stops offering the + // Calendar toggle for a view that configured none, and a view + // forced onto the calendar renderer reaches its refusal screen. + ...(calendarOptions ? { calendar: calendarOptions } : {}), // The date axis is resolved once, in ListView — this face only // forwards what the view declared, floored at 'name' // (objectui#3129, objectui#6557). See `timelineViewOptions`. diff --git a/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx new file mode 100644 index 0000000000..47af3cf87e --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx @@ -0,0 +1,138 @@ +/** + * 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#7029 — the refusal screen this component has always carried becomes + * REACHABLE from the object-page route. + * + * Ruled on objectstack#13748 (director batch #19, option A). Nothing in this + * file's component changed: `getCalendarConfig` already returned null for a + * schema with no date binding, and the early return already rendered "Calendar + * configuration required. Please specify startDateField and titleField." + * What changed is upstream — `ObjectView` and `ListView` stopped fabricating + * `due_date` / `start_date` bindings — so the props this component actually + * receives for an unconfigured view now carry no binding at all. + * + * These cases are therefore written as the SEAM: the two prop shapes the fixed + * upstream emits, asserted against the two screens they must produce. They are + * the layer that proves the deletion upstream reaches a user-visible outcome + * rather than merely changing an object literal. + * + * ⛔ 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 pass + * a refusal-only test: the CONTROL case asserts a correctly configured calendar + * still renders its events, on its own declared field, unchanged. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { ObjectCalendar } from './ObjectCalendar'; + +afterEach(cleanup); + +const REFUSAL = /Calendar configuration required/i; + +const today = new Date(); +const dayInThisMonth = (d: number) => + new Date(today.getFullYear(), today.getMonth(), Math.min(d, 28), 9, 0, 0, 0); + +const ROWS = [ + { id: 'r1', name: 'Ada out', start_date: dayInThisMonth(10).toISOString() }, + { id: 'r2', name: 'Grace out', start_date: dayInThisMonth(12).toISOString() }, +]; + +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 }), + getObjectSchema: vi.fn().mockResolvedValue(objectDef), + }) as any; + +describe('ObjectCalendar — an unconfigured view reaches the refusal screen (objectui#7029)', () => { + it('refuses the props a view with NO calendar block now produces', async () => { + // Exactly what the fixed `ListView` calendar branch emits for a view that + // declared nothing: the flat binding props are simply absent. Before this + // card the same view arrived carrying `startDateField: 'due_date'`, so this + // early return was unreachable and every record landed on today's cell + // under a display-name-resolved title — a plausible, fully wrong screen. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + // …and it is a refusal, not a calendar that merely looks empty. + expect(screen.queryByText('Ada out')).toBeNull(); + expect(screen.queryByText('Grace out')).toBeNull(); + }); + + it('refuses a HALF-declared block — a title with no date axis is not a calendar', async () => { + // The half-written declaration objectstack#13817 closes in the spec. The + // runtime is honest about it independently of which spec version the host + // pins — which is the whole reason this half was ruled worth fixing too. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + }); + + it('CONTROL: a correctly configured calendar is completely unaffected', async () => { + // The declared binding renders its events exactly as before — same fields, + // same records, no refusal. Without this case a fix that refused + // EVERYTHING would look identical to the fix that was ruled. + render( + , + ); + await waitFor(() => expect(screen.getByText('Ada out')).toBeTruthy()); + expect(screen.getByText('Grace out')).toBeTruthy(); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: the nested spec `calendar` block still configures the renderer', async () => { + render( + , + ); + await waitFor(() => expect(screen.getByText('Ada out')).toBeTruthy()); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 7bf9f454ea..b70d5ee437 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2233,19 +2233,34 @@ export const ListView = React.forwardRef(({ ...restKanban, }; } - case 'calendar': + case 'calendar': { + // objectui#7029: only ever restate a binding the view actually + // DECLARED. These two keys used to be floored at 'start_date' / + // 'end_date' — field names no view had written and most objects do not + // carry. `ObjectCalendar` decides whether it has a usable configuration + // by asking whether a start-date binding is present, so a fabricated + // one short-circuited its own refusal screen and every record landed on + // today. The `titleField` rung next door has always been conditional; + // these two now match it, and the whole branch matches the sibling + // faces that never invent (`resolveTimelineDateBinding` above, + // app-shell's `calendarViewOptions` / `defaultCalendarFromObject`). + const startDateField = + schema.calendar?.startDateField || schema.options?.calendar?.startDateField; + const endDateField = + schema.calendar?.endDateField || schema.options?.calendar?.endDateField; + const titleField = + schema.calendar?.titleField || schema.options?.calendar?.titleField; return { type: 'object-calendar', ...baseProps, - startDateField: schema.calendar?.startDateField || schema.options?.calendar?.startDateField || 'start_date', - endDateField: schema.calendar?.endDateField || schema.options?.calendar?.endDateField || 'end_date', - ...(schema.calendar?.titleField || schema.options?.calendar?.titleField - ? { titleField: schema.calendar?.titleField || schema.options?.calendar?.titleField } - : {}), + ...(startDateField ? { startDateField } : {}), + ...(endDateField ? { endDateField } : {}), + ...(titleField ? { titleField } : {}), ...(schema.calendar?.defaultView ? { defaultView: schema.calendar.defaultView } : {}), ...(schema.options?.calendar || {}), ...(schema.calendar || {}), }; + } case 'gallery': { // Merge spec config over legacy options into nested gallery prop const mergedGallery = { diff --git a/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx b/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx new file mode 100644 index 0000000000..dc37ab453e --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx @@ -0,0 +1,199 @@ +/** + * 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#7029 — what ListView hands the calendar renderer, and which views it + * offers the Calendar toggle to. + * + * Ruled on objectstack#13748 (director batch #19, option A): no invented + * calendar field names, anywhere on the route. `ObjectView` was one half (it + * fabricated `startDateField: 'due_date'`); this branch was the other, and it + * is the half that decides whether the fix is observable at all — with + * `ObjectView` 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 field does the calendar bucket by? + * - the CAPABILITY gate (`availableViews`) — may this view offer Calendar at + * all? ADR-0047: a visualization is offered only when its binding resolves. + * A view that declared no calendar block resolved one anyway, so the + * toggle was live on every object view in the product. + * + * 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 out', start_date: '2099-09-01', end_date: '2099-09-03' }, + { id: '2', name: 'Grace out', 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-calendar', + (props: Record) => { + captured.push(props); + return
; + }, + { namespace: 'test', label: 'Calendar 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: 'calendar', + columns: ['name'], +} as const; + +/** Mount ListView on `schema` and return the props the calendar was given. */ +async function calendarProps(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 calendar branch — only ever restates a DECLARED binding (objectui#7029)', () => { + it('invents NO binding for a calendar 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 `getCalendarConfig` return null downstream, which is + // the only route to the renderer's refusal screen. + const props = await calendarProps({ ...BASE }); + expect(props.startDateField).toBeUndefined(); + expect(props.endDateField).toBeUndefined(); + expect(props.titleField).toBeUndefined(); + }); + + it('invents no binding when the view carries an EMPTY calendar block', async () => { + // The half-written declaration the spec half (objectstack#13817) closes: + // `allowedVisualizations: ['calendar']` with nothing under `calendar:`. + const props = await calendarProps({ ...BASE, calendar: {} }); + expect(props.startDateField).toBeUndefined(); + expect(props.endDateField).toBeUndefined(); + }); + + it('CONTROL: forwards the spec-canonical `calendar` block unchanged', async () => { + const props = await calendarProps({ + ...BASE, + calendar: { 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.calendar` 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 calendarProps({ + ...BASE, + options: { calendar: { 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 block keeps its declared half and only that', async () => { + const props = await calendarProps({ ...BASE, calendar: { startDateField: 'start_date' } }); + expect(props.startDateField).toBe('start_date'); + expect(props.endDateField).toBeUndefined(); + }); +}); + +describe('ListView capability gate — the Calendar toggle follows the binding (objectui#7029)', () => { + 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. Copied from + * `ListView.test.tsx`'s own helpers on purpose: querying without opening + * returns null for BOTH worlds, which would make the negative case below pass + * while measuring nothing (measured: it did, on the first run of this file). + */ + 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 Calendar to a view that declared a binding', async () => { + // The positive control comes FIRST here: 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, calendar: { startDateField: 'start_date' } }); + await waitFor(() => expect(queryViewOption('Calendar')).toBeInTheDocument()); + }); + + it('does NOT offer Calendar to a view that declared no calendar binding', async () => { + // ADR-0047: offered only when the binding resolves. Before this card the + // object page's fabricated `options.calendar.startDateField` resolved for + // every view in the product, so this toggle was always live — the "disable + // the calendar toggle for such views" half of the ruling, obtained here by + // deleting the fabrication rather than by adding a second mechanism. + await mountSwitcher({ ...GRID, appearance: { allowedVisualizations: ['grid', 'calendar'] } }); + // (No `Grid` sanity assertion here: the switcher TRIGGER also carries + // aria-label="Grid", so querying that name matches two elements and throws. + // The positive control above is what proves this harness can see options.) + expect(queryViewOption('Calendar')).not.toBeInTheDocument(); + }); +}); From ed923b8c2b150dd6e3296a6659f1cc0217675907 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:39:08 +0000 Subject: [PATCH 2/3] fix(plugin-view): stop inventing calendar bindings on the element route too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generateViewSchema` runs when no host supplies `renderListView` — the authored `object-view` element — so it bypasses `ListView` and carried its own copy of the fabrication (`start_date` / `end_date` / `name`). Same defect, same ruling (objectstack#13748: no invented field names either way); fixing only the console route would have left this one producing the same wrong screen. Also retargets `ObjectView.titleFieldConvergence.test.tsx`: its `calendar` column pinned the `|| 'name'` floor this card deletes, and the seam count drops from seven to six. What objectui#6557 owns is unchanged and still pinned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../ObjectView.titleFieldConvergence.test.tsx | 35 +++++- packages/plugin-view/src/ObjectView.tsx | 21 +++- .../ObjectView.calendarBinding-7029.test.tsx | 108 ++++++++++++++++++ 3 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx diff --git a/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx b/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx index 1249ba4aab..ef1381c99c 100644 --- a/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx +++ b/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx @@ -43,6 +43,20 @@ * honour" case goes RED for that kind alone (it reads `'headline'`), while both * control cases and the calendar/gantt columns stay green. * + * ⚠️ FIXTURE TRIAGE, objectui#7029. The `calendar` column below used to read + * `'name'` for a view that declared nothing, and was cited here as one of the + * two seams that "already used two rungs". objectui#7029 (ruled on + * objectstack#13748) deleted the calendar seam outright: a view with no + * `calendar:` block now yields NO `options.calendar` at all, because the + * fabricated `startDateField: 'due_date'` / `titleField: 'name'` pair made + * `ObjectCalendar`'s refusal screen unreachable. So this file's calendar column + * is `undefined` for an undeclared view — the assertions were RETARGETED, not + * respelled, and the seam count below dropped from seven to six. What objectui#6557 + * actually owns is unchanged and still pinned: no seam reads `objectDef`, and + * every seam that still HAS a floor is a chain of view-declared rungs. The + * declared-config control two cases down is the one that proves the retarget + * did not simply delete coverage: `calendar: 'v_calendar'` still resolves. + * * The last case is structural rather than behavioural on purpose: the four * inline seams are closures inside `ObjectViewInner`, and "these five now have * the same SHAPE as those two" is a statement about the expressions, not about @@ -221,7 +235,9 @@ describe('ObjectView view-config `titleField` — the middle rung is gone (objec map: 'name', gallery: 'name', tree: 'name', - calendar: 'name', + // objectui#7029: the calendar seam no longer exists — an undeclared view + // gets no `options.calendar` bag, so there is no title to floor. + calendar: undefined, gantt: 'name', }); }); @@ -235,7 +251,9 @@ describe('ObjectView view-config `titleField` — the middle rung is gone (objec map: 'name', gallery: 'name', tree: 'name', - calendar: 'name', + // objectui#7029: the calendar seam no longer exists — an undeclared view + // gets no `options.calendar` bag, so there is no title to floor. + calendar: undefined, gantt: 'name', }); }); @@ -291,8 +309,17 @@ describe('the seven seams share ONE expression shape (objectui#6557)', () => { /** Every `titleField:` / `labelField:` assignment in the file. */ const seamLines = SOURCE.split('\n').filter((l) => /^\s*(titleField|labelField):/.test(l)); - it('there are exactly seven of them', () => { - expect(seamLines).toHaveLength(7); + it('there are exactly six of them', () => { + // Seven until objectui#7029 removed the calendar seam. The count is the + // tripwire: a new view kind copied from a sibling shows up here first. + expect(seamLines).toHaveLength(6); + }); + + it('and the calendar seam is not one of them', () => { + // Pinned explicitly rather than left implicit in the count above, so a + // future edit that re-adds a calendar title floor fails with the reason + // rather than with an off-by-one (objectui#7029). + expect(seamLines.filter((l) => /calendar/.test(l))).toEqual([]); }); it('none reads the object definition', () => { diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index ae4d44cdbf..48582ab47a 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1313,12 +1313,27 @@ export const ObjectView: React.FC = ({ }; } case 'calendar': + // objectui#7029: the SECOND route to `ObjectCalendar`. `generateViewSchema` + // runs precisely when no host supplied `renderListView` — the authored + // `object-view` element — so it never passes through `ListView`, and the + // deletion this card made in app-shell + plugin-list does not reach it. + // Left alone it would keep fabricating the same three bindings for a view + // that declared none, which is what makes the renderer's own refusal + // screen unreachable (it decides by asking whether a start-date binding + // is PRESENT). Ruled on objectstack#13748: ⛔ either way no invented field + // names — so both routes forward only what the author declared. return { type: 'object-calendar', ...baseProps, - startDateField: viewOptions.calendar?.startDateField || 'start_date', - endDateField: viewOptions.calendar?.endDateField || 'end_date', - titleField: viewOptions.calendar?.titleField || 'name', + ...(viewOptions.calendar?.startDateField + ? { startDateField: viewOptions.calendar.startDateField } + : {}), + ...(viewOptions.calendar?.endDateField + ? { endDateField: viewOptions.calendar.endDateField } + : {}), + ...(viewOptions.calendar?.titleField + ? { titleField: viewOptions.calendar.titleField } + : {}), ...(viewOptions.calendar || {}), }; case 'gallery': diff --git a/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx new file mode 100644 index 0000000000..d96d856704 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx @@ -0,0 +1,108 @@ +/** + * 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#7029 — the SECOND route to `ObjectCalendar` invents nothing either. + * + * Ruled on objectstack#13748 (director batch #19, option A): ⛔ either way no + * invented field names. The card measured the console route + * (`app-shell/ObjectView` → `plugin-list/ListView` → `ObjectCalendar`); this + * file covers the other one. `generateViewSchema` runs precisely when no host + * supplied `renderListView` — the authored `object-view` element, which is what + * `examples/schema-catalog`'s object-view fixtures drive — so it bypasses + * `ListView` entirely and carried its OWN copy of the fabrication: + * `startDateField: 'start_date'`, `endDateField: 'end_date'`, + * `titleField: 'name'` for a view that declared none. + * + * Fixing only the console route would have left this one rendering the same + * plausible, fully wrong screen the card exists to remove, and would have made + * the fix's own claim ("no invented field names") false in the repo it was + * merged into. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore the three `||` floors in this branch and the "invents NO binding" + * case goes RED (it reads the fabricated names) while the declared-config + * 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 renderCalendarView(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 — calendar restates only a DECLARED binding (objectui#7029)', () => { + it('invents NO binding for a calendar view that declares no config', async () => { + const schema = await renderCalendarView({}); + expect(schema.type).toBe('object-calendar'); + // All three used to be fabricated here. Absent bindings are the only route + // to `ObjectCalendar`'s refusal screen. + expect(schema.startDateField).toBeUndefined(); + expect(schema.endDateField).toBeUndefined(); + expect(schema.titleField).toBeUndefined(); + }); + + it('CONTROL: forwards a declared calendar block unchanged', async () => { + // A correctly configured calendar renders exactly as it did before this + // card — without this case a fix that emitted nothing at all would look + // identical to the fix that was ruled. + const schema = await renderCalendarView({ + calendar: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'subject' }, + }); + expect(schema.type).toBe('object-calendar'); + expect(schema.startDateField).toBe('start_date'); + expect(schema.endDateField).toBe('end_date'); + expect(schema.titleField).toBe('subject'); + }); + + it('CONTROL: a partially declared block keeps its declared half and only that', async () => { + const schema = await renderCalendarView({ calendar: { startDateField: 'start_date' } }); + expect(schema.startDateField).toBe('start_date'); + expect(schema.endDateField).toBeUndefined(); + expect(schema.titleField).toBeUndefined(); + }); +}); From 680fea79560278f76cfe8a89a9e841d3ad834930 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:52:41 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20objectui#7029=20?= =?UTF-8?q?=E2=80=94=20no=20invented=20calendar=20field=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../7029-no-invented-calendar-fields.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .changeset/7029-no-invented-calendar-fields.md diff --git a/.changeset/7029-no-invented-calendar-fields.md b/.changeset/7029-no-invented-calendar-fields.md new file mode 100644 index 0000000000..37fef232ea --- /dev/null +++ b/.changeset/7029-no-invented-calendar-fields.md @@ -0,0 +1,46 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-list': minor +'@object-ui/plugin-view': minor +--- + +Calendar views no longer render on invented field names (objectui#7029; ruled on +objectstack#13748, director batch #19, option A). + +A view that carried no `calendar:` block used to have a complete-looking calendar +configuration synthesized for it. `ObjectCalendar` has always decided whether it +has a usable configuration by asking whether a start-date binding is PRESENT, so +the fabrication short-circuited its own refusal screen — "Calendar configuration +required. Please specify startDateField and titleField." — which existed all +along and was simply unreachable. Measured on a leave-request object whose real +fields are `start_date` / `end_date`: every record piled onto today's cell under +titles resolved through the display-name chain. A plausible, fully wrong screen, +with zero signal to the author. + +Three faces were fabricating, on two independent routes to the same renderer: + +- `app-shell/ObjectView` emitted `startDateField: 'due_date'` and + `titleField: 'name'` into `options.calendar` for every object view; +- `plugin-list/ListView`'s calendar branch floored the same two bindings at + `'start_date'` / `'end_date'` one layer down; +- `plugin-view/ObjectView.generateViewSchema` — the authored `object-view` + element route, which bypasses `ListView` entirely — carried its own copy. + +All three now forward only what the author declared. This converges the calendar +on the shape its siblings already had: `timelineViewOptions` (objectui#3129 +retired this very literal from the timeline axis), the kanban lane detector +(ADR-0085, "never invents a field the object doesn't have"), and +`defaultCalendarFromObject` (a binding, or nothing). + +**Behaviour change, loud over silent.** With no binding to forward, ADR-0047's +capability gate stops offering the Calendar toggle to views that configured +none, and a view forced onto the calendar renderer reaches the refusal screen +instead of a wrong one. A view that happened to sit on an object carrying a real +`due_date` field was rendering by luck; it now refuses until its `calendar:` +block is written. Correctly configured calendars are unaffected — same fields, +same render. The same deletion also stops the fabricated name from answering for +the Timeline switcher, which accepts a calendar binding as a legitimate axis. + +The spec half — cross-field validation rejecting a half-written declaration at +authoring time — is objectstack#13817. This half makes the runtime honest +independent of which spec version the host pins.