Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/8170-calendar-refusal-names-an-actionable-remedy.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/plugin-calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
const { container } = render(
<ObjectCalendar
schema={{ type: 'object-calendar', objectName: 'crm_leave_request' } as any}
dataSource={makeDataSource()}
/>,
);
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(
<ObjectCalendar
schema={
{
type: 'object-calendar',
objectName: 'crm_leave_request',
startDateField: 'start_date',
titleField: 'name',
} as any
}
dataSource={makeDataSource()}
/>,
);
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(
<ObjectCalendar
schema={
{
type: 'object-calendar',
objectName: 'crm_leave_request',
startDateField: 'start_date',
} as any
}
dataSource={makeDataSource()}
/>,
);
await waitFor(() => expect(screen.getByText('Ada out')).toBeTruthy());
expect(screen.getByText('Grace out')).toBeTruthy();
expect(screen.queryByText(REFUSAL)).toBeNull();
});
});
82 changes: 76 additions & 6 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1119,12 +1122,79 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
);
}

/**
* 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 (
<div className={className}>
<div className="flex items-center justify-center h-96">
<div className="text-muted-foreground">
Calendar configuration required. Please specify startDateField and titleField.
<div className="text-muted-foreground max-w-md text-center space-y-2">
<p>
Calendar configuration required. Please specify startDateField, the calendar's one
required key; the event title resolves without titleField.
</p>
<p className="text-sm">
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.
</p>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading