diff --git a/.changeset/8170-calendar-refusal-names-an-actionable-remedy.md b/.changeset/8170-calendar-refusal-names-an-actionable-remedy.md new file mode 100644 index 0000000000..c2af63acf1 --- /dev/null +++ b/.changeset/8170-calendar-refusal-names-an-actionable-remedy.md @@ -0,0 +1,48 @@ +--- +'@object-ui/plugin-calendar': patch +--- + +`ObjectCalendar`'s refusal screen now states a remedy an author can act on, on every +door it is reachable from (objectui#8170). + +It used to read "Calendar configuration required. Please specify startDateField and +titleField." The first clause was right and is unchanged. The second was wrong twice: + +**It demanded an optional key.** `@objectstack/spec`'s `CalendarConfigSchema` is a +strict object whose ONE required key is `startDateField`. Re-measured on the installed +17.4.0, three legs via `CalendarConfigSchema.safeParse` from `@objectstack/spec/ui`: +`{}` and `{ titleField: 't' }` both fail `invalid_type` at `startDateField`, and +`{ startDateField: 'd' }` parses clean. The spec's own note on that schema names this +renderer as the reason — `resolveTitle` takes an explicit `titleField` when present and +otherwise resolves through the ADR-0079 record display-name chain, so the screen was +asking for a key the component neither needs nor reads. + +**It named keys without saying where they go, which is unactionable on an interface +page.** That surface's `interfaceConfig` has no calendar slot at all — it reads +`columns`, `sort`, `filterBy`, `userFilters`, `appearance`, `addRecord`, `userActions`, +`showRecordCount`, `source`, `sourceView`, `buttons` and `recordAction`, and no calendar +key — so the only lever there is `sourceView`, which the screen never mentioned. The +refusal was honest about the binding and dishonest about the remedy. + +The new copy names the one required key, says the title resolves without `titleField`, +points at the view's `calendar` block as the place both doors read the binding from, and +states the interface page's indirection outright. + +**Why the wording is door-COMPLETE rather than door-AWARE.** Two producers emit an +`object-calendar` node — the calendar branches of `plugin-list`'s `ListView` and +`plugin-view`'s `ObjectView` — and app-shell reaches the first from both its `ObjectView` +(object-view door) and `InterfaceListPage` (interface-page door). Every one of them hands +the component the same shared `baseProps` bag plus whichever declared binding keys exist: +nothing on the node names the door. A door-aware screen therefore needs a newly declared +prop threaded through four packages, and inferring the door from whether the object +carries a date field only correlates with it — `InterfaceListPage`'s deriver runs solely +when `calendar` is whitelisted in `appearance.allowedVisualizations` — which is the +"the gate and the seam must answer one question" failure this repo has recorded on `map`, +`chart` and `kanban` already. + +The first clause is unchanged on purpose: five suites pin this screen with +`/Calendar configuration required/i`, and `@object-ui/types`' calendar alias tombstones +assert on the same phrase. Only the clause that was wrong moves. The new second clause is +pinned in `ObjectCalendar.refusalRemedy-8170.test.tsx`, together with the runtime control +that makes its claim checkable: a calendar with a date binding and no `titleField` renders +real record titles. diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index 6659a53485..95d84b2487 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -271,6 +271,13 @@ configuration on it, so a calendar whose title and end keys are spelled correctl still renders the "Calendar configuration required" refusal screen and never reads them. +`startDateField` is the only key that gate asks for — `titleField` is optional, +and an event with no explicit title resolves one through the ADR-0079 record +display-name chain. The refusal screen says so, and it also names where the key +belongs: the view's `calendar` block. That matters on an **interface page**, +whose `interfaceConfig` has no calendar slot of its own — the only lever there +is `sourceView`, pointed at a view that declares the block (objectui#8170). + ```typescript import type { ObjectCalendarSchema } from '@object-ui/types'; diff --git a/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx new file mode 100644 index 0000000000..c60e6598af --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.refusalRemedy-8170.test.tsx @@ -0,0 +1,181 @@ +/** + * 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#8170 — the refusal screen's SECOND clause: the remedy. + * + * objectui#7029 made this screen REACHABLE and deliberately left its copy + * alone; `ObjectCalendar.unconfiguredRefusal-7029.test.tsx` next door pins + * that reachability with `/Calendar configuration required/i`, the FIRST + * clause, and nothing else. This file owns the second clause, which is the + * half that was wrong twice over: + * + * 1. it demanded `titleField`, which `@objectstack/spec`'s + * `CalendarConfigSchema` does not require and this component does not + * read when it is absent; and + * 2. it named two keys as things to "specify" without saying WHERE, which is + * unactionable on the interface-page door — that surface has no calendar + * slot at all, and its only lever is `sourceView`. + * + * ## Why the copy is door-COMPLETE rather than door-AWARE + * + * Measured on the branch base: two producers emit an `object-calendar` node — + * the calendar branch of `plugin-list`'s `ListView` and the one in + * `plugin-view`'s `ObjectView` — and app-shell reaches the first from both + * `ObjectView` (the object-view door) and `InterfaceListPage` (the + * interface-page door). All of them hand this component the same shared + * `baseProps` bag plus whichever declared binding keys exist. Nothing on the + * node names the door, so this component cannot branch on it without a new + * declared prop threaded through four packages. The screen therefore names the + * one place every door reads the binding from, and states the page door's + * indirection outright. + * + * ## What this file can and cannot prove + * + * ⛔ It does NOT re-measure the spec. `@objectstack/spec` is not a runtime + * dependency of this package, and `@object-ui/types`' local mirror of the + * calendar block is `.partial()`, so asking it whether `titleField` is + * required would return a confident answer to a different question. The three + * legs live in the changeset and are reproducible with + * `CalendarConfigSchema.safeParse` from `@objectstack/spec/ui`. + * + * ⭐ What it DOES prove is the half this package owns and the screen now + * asserts to the user: a calendar with a date binding and NO `titleField` + * renders real titles, through `resolveTitle`'s ADR-0079 display-name chain. + * Without that case the new sentence would be an unbacked promise. + */ + +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); + +/** The clause objectui#8170 deliberately did NOT move — five suites pin it. */ +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; + +/** + * The whole refusal block as one whitespace-normalised string. The copy spans + * two paragraphs, so a single `getByText` would only ever see one of them. + */ +async function readRefusalScreen(): Promise { + const { container } = render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + return (container.textContent ?? '').replace(/\s+/g, ' ').trim(); +} + +describe('ObjectCalendar refusal screen — the remedy it states (objectui#8170)', () => { + it('still opens with the clause every sibling suite pins', async () => { + // The guard on this card's own blast radius. Reword the FIRST clause and + // five other suites go red at once; this case is here so that failure has + // a named owner instead of arriving as five unexplained reds. + expect(await readRefusalScreen()).toContain('Calendar configuration required'); + }); + + it('⛔ no longer demands `titleField` alongside the date key', async () => { + const text = await readRefusalScreen(); + // The exact shape of the retired sentence. This is the assertion that + // fails against the pre-card wording and passes against the new one. + expect(text).not.toMatch(/startDateField\s+and\s+titleField/i); + // …and it is a REWORDING, not a deletion: the one key the spec requires is + // still named, so an author still knows what to write. + expect(text).toContain('startDateField'); + // The claim the screen now makes about the optional key is stated, not + // merely implied by its absence — the CONTROL case below is its proof. + expect(text).toMatch(/resolves without titleField/i); + }); + + it('names a remedy the INTERFACE-PAGE door can actually act on', async () => { + const text = await readRefusalScreen(); + // `InterfaceListPage` reads no calendar key off `interfaceConfig` at all, + // so "specify startDateField" is not something a page author can do. The + // lever that door does have is `sourceView`. + expect(text).toContain('sourceView'); + }); + + it('names the place the OBJECT-VIEW door writes the binding', async () => { + const text = await readRefusalScreen(); + // The shared half: both doors resolve the binding out of the view's + // `calendar` block, which is why one sentence can serve both. + expect(text).toMatch(/calendar block/i); + }); + + it('CONTROL: a configured calendar still renders and shows no refusal', async () => { + // Without this, a change that refused everything — or one that broke the + // component outright — would satisfy every assertion above. + render( + , + ); + await waitFor(() => expect(screen.getByText('Ada out')).toBeTruthy()); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('⭐ CONTROL: the screen’s claim is true — no `titleField`, real titles', async () => { + // This is the runtime half of "the event title resolves without + // titleField". `resolveTitle` takes the explicit key when present and + // otherwise goes through `getRecordDisplayName`, the ADR-0079 chain. If + // that ever regresses, the sentence on the refusal screen becomes a lie + // and this case is what says so. + render( + , + ); + await waitFor(() => expect(screen.getByText('Ada out')).toBeTruthy()); + expect(screen.getByText('Grace out')).toBeTruthy(); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 77b6725761..1a357226dc 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -201,10 +201,13 @@ export interface ObjectCalendarComponentProps { * reached the retired arm. * - A schema whose only configuration lived under the retired spelling now * returns null from here, and the early return answers null with the - * existing "Calendar configuration required. Please specify startDateField - * and titleField." refusal screen. The map fell back to DEFAULT field names, - * which looks like bad data and is why it had to warn; the calendar names - * what is missing on screen. Nothing is dropped without a trace. + * existing "Calendar configuration required" refusal screen. The map fell + * back to DEFAULT field names, which looks like bad data and is why it had + * to warn; the calendar names what is missing on screen. Nothing is dropped + * without a trace. (That screen's SECOND clause was reworded by + * objectui#8170 — it names `startDateField` and the view's `calendar` block + * now, and no longer demands the optional `titleField`. This citation is + * deliberately clipped to the clause that did not move.) * * ⛔ No compatibility rung and no deprecation window, per AGENTS.md #0.1: a * tolerant fallback fossilizes the wrong convention into a second de-facto @@ -1119,12 +1122,79 @@ export const ObjectCalendar: React.FC = ({ ); } + /** + * THE REFUSAL SCREEN — honest about the binding, and now honest about the + * REMEDY too (objectui#8170). + * + * It used to read "Calendar configuration required. Please specify + * startDateField and titleField." Two independent halves of that were wrong, + * and the measurements are recorded here because the sentence is quoted in + * several other files and a future reader has to be able to tell a + * deliberate wording from a drifted one. + * + * ## ① `titleField` is not required, and this file is why + * + * `@objectstack/spec`'s `CalendarConfigSchema` is a `strictObject` whose ONE + * required key is `startDateField`; `titleField` is optional. Re-measured on + * the installed 17.4.0, three legs: `{}` and `{ titleField: 't' }` both fail + * `invalid_type` at `startDateField`, and `{ startDateField: 'd' }` parses + * CLEAN. The spec's own note on that schema names THIS renderer as the + * reason — `resolveTitle` above takes an explicit `titleField` when present + * and otherwise resolves through the ADR-0079 record display-name chain, so + * demanding the key here asked the author for something the component does + * not need and already handles. + * + * ## ② The remedy has to hold on BOTH doors, because this screen cannot see + * which one it was reached through + * + * Measured, and it is the reason this is a WORDING change rather than a + * door-aware one. Two producers emit an `object-calendar` node — the + * calendar branch of `plugin-list`'s `ListView` and the one in + * `plugin-view`'s `ObjectView` — and app-shell reaches the first from two + * faces, `ObjectView` (the object-view door) and `InterfaceListPage` (the + * interface-page door). Every one of them hands this component the same + * shape: the shared `baseProps` bag plus whichever of `startDateField` / + * `endDateField` / `titleField` the author declared. NOTHING on the node + * names the door, so there is nothing to infer from, and a door PROP would + * have to be declared on the node's published schema and threaded through + * four packages. + * + * ⛔ And it must not be guessed from the object either. The temptation is to + * ask whether `objectSchema` carries a date field, since + * `InterfaceListPage.defaultCalendarFromObject` derives a binding from the + * first one it finds and this screen is therefore only reachable on that + * door when the derivation came back empty. That correlation is not an + * identity — the deriver only runs when `calendar` is whitelisted in + * `appearance.allowedVisualizations` — and pinning the copy to a predicate + * living in another package is the "the gate and the seam must answer one + * question" hazard this repo has now recorded on `map`, `chart` and `kanban`. + * + * ⇒ so the screen names the one place BOTH doors read the binding from — the + * view's `calendar` block — and then states the page door's indirection + * plainly, because that door genuinely has no slot of its own: + * `InterfaceListPage` reads `columns`, `sort`, `filterBy`, `userFilters`, + * `appearance`, `addRecord`, `userActions`, `showRecordCount`, `source`, + * `sourceView`, `buttons` and `recordAction` off `interfaceConfig`, and NO + * calendar key at all. + * + * ⛔ The first clause is unchanged ON PURPOSE. Five suites pin this screen + * with `/Calendar configuration required/i` and `@object-ui/types`' alias + * tombstones assert on the same phrase; the clause that was wrong is the + * second one, and only the second one moves. + */ if (!calendarConfig) { return (
-
- Calendar configuration required. Please specify startDateField and titleField. +
+

+ Calendar configuration required. Please specify startDateField, the calendar's one + required key; the event title resolves without titleField. +

+

+ It belongs on the view's calendar block. An interface page has no calendar slot of + its own: point its sourceView at a view that declares one. +

diff --git a/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx index 47af3cf87e..34e1ab8da3 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.unconfiguredRefusal-7029.test.tsx @@ -12,11 +12,11 @@ * * 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. + * schema with no date binding, and the early return already rendered the + * "Calendar configuration required" screen. 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 @@ -26,6 +26,12 @@ * ⛔ The refusal screen itself is deliberately NOT redesigned by this card — * these cases read its existing copy verbatim. * + * ⚠️ objectui#8170 DID later reword that copy, and these cases still hold + * because they match the clause it deliberately left alone: the `REFUSAL` + * matcher below is the first clause only. The second clause — which key to + * specify, and where it lives on each door — is pinned in this directory's + * `ObjectCalendar.refusalRemedy-8170.test.tsx`, not here. + * * 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. diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx index 2ea2885127..e7220327a2 100644 --- a/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx @@ -33,10 +33,12 @@ * ground on which the rider was omitted. * 2. When the retired spelling is the only place a config was written, this * component returns null and the early return renders the existing - * "Calendar configuration required. Please specify startDateField and - * titleField." screen. The map fell back to DEFAULT field names — an empty - * map that looks like bad data — which is exactly why it had to warn. The - * calendar already says what is missing, by name, on screen. + * "Calendar configuration required" screen. The map fell back to DEFAULT + * field names — an empty map that looks like bad data — which is exactly + * why it had to warn. The calendar already says what is missing, by name, + * on screen. (objectui#8170 reworded that screen's SECOND clause; the + * `REFUSAL` matcher below reads the first clause only, which is why this + * file is unaffected by it and why this citation is clipped to it.) * * BOTH DIRECTIONS ARE PINNED, because a fix that simply stopped reading the * filter would also pass a retirement-only file: the second group asserts that