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. 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.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/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(); + }); +}); 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(); + }); +});