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
4 changes: 4 additions & 0 deletions .changeset/object-calendar-member-pins-8071.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Test-only change: `object-calendar`'s `calendar` and `dataSource` gain per-block member pins (objectui#8071 slice 5), closing out the block entirely, and the member-pin exemption ledger in the console's registry/spec parity gate moves with them. No published behaviour changes — no runtime source, build entry, `files[]` payload or published-contract field is touched.
70 changes: 60 additions & 10 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2193,10 +2193,18 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts',
pins: 'The I18nLabel trio — see `element:text_input.description` (objectui#5717).',
},
'object-calendar.calendar': {
file: 'packages/plugin-calendar/src/__tests__/objectCalendarConfigMembers-8071.test.tsx',
pins: 'Members are FIELD NAMES the calendar projects onto an event: `startDateField` and `endDateField` name the record fields that become the event\'s `start`/`end` (an unauthored `endDateField` leaves `end` undefined rather than inventing one), and `titleField` OUTRANKS the object\'s own default display-name resolution (`getRecordDisplayName`) rather than only supplementing it. The sharp claim is PRECEDENCE: `getCalendarConfig` returns `schema.calendar` OUTRIGHT the moment it is set — never merged against the flat legacy spelling (`startDateField/endDateField/titleField/colorField` authored directly on the schema) it falls back to only when `calendar` is absent — so a schema carrying BOTH, with the two disagreeing, is read entirely from the nested object and not at all from the flat siblings, even for the keys the nested object leaves unset. `colorField`\'s own resolution ladder and `allDayField` (an objectui-local extra key inside the same object) are each pinned narrowly already and are not re-asserted here: `ObjectCalendar.colorFieldLadder-7243.test.tsx`, `ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`. The spec side is a `strictObject` of exactly the four documented keys, so it fixes the NAMES but nothing about how they are used once read — the read site is the whole member contract (objectui#8071).',
},
'object-calendar.data': {
file: 'packages/plugin-calendar/src/__tests__/ObjectCalendar.recordSourceMembers-8314.test.tsx',
pins: 'The members are RECORDS, and the keys read inside one are the fields the declared `calendar` config names plus `id`: every member arrives in authored order, `allDay` is derived PER MEMBER from the declared end field, a member with no value in the start field is counted in the unscheduled area rather than dropped or given a fabricated date (objectui#7071 at the member level), and a member with no `id` gets a synthesised one rather than being discarded. Asserted through the REAL `SchemaRenderer`, because this key\'s sink is the props channel `index.tsx` resolves (`resolveExternalData`), not the schema. ⛔ NOT pinned on "the internal query is skipped": an authored array reaches this renderer on BOTH carriers, and the schema-channel one returns it as a config with no `provider`, so no query is issued on a tree where the boundary drops `data` either — measured by ablation, that row stays green while the ROWS-ARE-DRAWN rows go red, so it is kept as a labelled companion and the rows carry the claim. The spec side cannot supply any of it: the row is `z.array(z.unknown())`, so every coarse member kind parses and the read site is the whole member contract (objectui#8314).',
},
'object-calendar.dataSource': {
file: 'packages/plugin-calendar/src/ObjectCalendar.elementDataSource.test.tsx',
pins: 'The per-element binding\'s own members, read through the REAL `ElementDataSourceGate` + renderer pair and the same shared mapping mechanism `element:record_picker.dataSource` and `record:related_list.dataSource` already pin: `object` WRITES onto `objectName` (this block\'s own top-level key), a named `view`\'s `filter`/`sort` reach the fetch as `$filter`/`$orderby`, a `sort` member missing `order` reads as ascending rather than dropped (the shared `QueryParams.$orderby` member contract, objectui#4022), an unresolvable `view` reports instead of fetching the whole object, and a calendar carrying no binding at all behaves exactly as it did before one existed. `columns` and a row cap are deliberately UNMAPPED and asserted absent from the mapping\'s own declaration (`OBJECT_CALENDAR_DATA_SOURCE = { filter: true, sort: true }` in `index.tsx`) rather than pinned here, because this block projects fields off its own `calendar` config and fetches the whole window — neither key has a read site to write to. Pre-existing file, promoted to a pin here after being read end to end (objectstack#6953, objectui#8071).',
},
'object-calendar.filter': {
file: 'packages/plugin-calendar/src/__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx',
pins: 'The members are ObjectQL `$filter` elements, and this renderer adds nothing to that contract and subtracts nothing from it: the authored value reaches `dataSource.find` as `$filter` BY IDENTITY (`toBe`, so a normalising rewrite cannot pass), and the retired `filter.calendar` member spelling yields NO configuration — one authored key read twice with two incompatible meanings is the member-level defect objectui#7711 closed here and objectui#4034 closed on the map. Both member forms are covered (the object form and the array-of-arrays form). The spec side cannot supply this: the `object-calendar` `filter` row is `z.unknown()`, so the wire is the only member contract there is (objectui#7711, registered as a pin by objectui#8176 once objectui#8186 declared the key).',
Expand Down Expand Up @@ -2502,27 +2510,37 @@ const MEMBER_PIN_EXEMPTIONS: Record<string, string> = {
// DIFFERENT reason: see the constant.
'record:related_list.actions': NO_READ_SITE_TO_PIN,

// objectui#8176 — the four this direction could not see. See
// object-calendar — objectui#8176 brought both `calendar` and `dataSource`
// into this population (see `NEWLY_JUDGED_UNPINNED_MEMBERS`'s docblock);
// objectui#8071 slice 5 pinned both, so the block is now fully pinned and
// this header stays only as a note for the next reader who greps for it.

// objectui#8176 — the other two this direction could not see. See
// `NEWLY_JUDGED_UNPINNED_MEMBERS` below, which pins them BY NAME so the
// ceiling correction cannot absorb anything else.
'object-calendar.calendar': AWAITING_A_PIN_NEWLY_JUDGED,
'object-calendar.dataSource': AWAITING_A_PIN_NEWLY_JUDGED,
'object-kanban.columns': AWAITING_A_PIN_NEWLY_JUDGED,
'object-kanban.dataSource': AWAITING_A_PIN_NEWLY_JUDGED,
};

/**
* The four ids the ceiling below moves for, named so the move is auditable.
* The ids the ceiling below moved for, named so the move is auditable, MINUS
* whichever of them objectui#8071 has since converted to a real pin.
*
* A ceiling that goes up is worth exactly as much as the account of why, and
* "+4, trust me" is not an account. Pinning the four by name means the
* correction admits four SPECIFIC pre-existing keys and nothing else: a fifth
* entry cannot hide inside the same headroom, because `the member-pin exemption
* list only ratchets DOWN` still reads the total.
* correction admitted four SPECIFIC pre-existing keys and nothing else: a
* fifth entry cannot hide inside the same headroom, because `the member-pin
* exemption list only ratchets DOWN` still reads the total.
*
* This list is CHECKED against the live `MEMBER_PIN_EXEMPTIONS` state (`the
* ceiling correction admits exactly the four keys objectui#8176 made
* visible`), so it shrinks as the four are pinned rather than staying a
* frozen record of four forever — the frozen record of WHY the ceiling went
* up is `MEMBER_PIN_EXEMPTION_CEILING`'s own docblock below, not this array.
* objectui#8071 slice 5 pinned `object-calendar.calendar` and
* `object-calendar.dataSource`, so two of the original four remain here.
*/
const NEWLY_JUDGED_UNPINNED_MEMBERS = [
'object-calendar.calendar',
'object-calendar.dataSource',
'object-kanban.columns',
'object-kanban.dataSource',
];
Expand Down Expand Up @@ -2629,11 +2647,43 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS = [
* suite is an ActionDef's own PER-ACTION field, a different mechanism — so it
* is a new file.
*
* ## 48 -> 46, the fifth slice, and a whole `object-calendar` block closed
*
* objectui#8071's fifth slice converted the two keys objectui#8176 had left
* unpinned on `object-calendar` — `calendar` and `dataSource` — and deleted
* their two entries, so the ceiling follows to 46 in the same commit. Every
* OTHER key this block declares (`data`, `filter`, `sort`, `staticData`) was
* already pinned, so the block now carries zero exemptions, the same shape
* `element:record_picker` (slice 3) and `record:quick_actions` (slice 4)
* closed in — chosen for exactly that reason: a two-key remainder on one
* already-mostly-pinned block, closeable outright rather than left with a
* straggler.
*
* `dataSource` is the per-element binding every `elementDataSourceBlock`
* publishes (`ELEMENT_DATA_SOURCE_INPUT`, `@object-ui/core`) — the same
* mechanism `element:record_picker.dataSource` and
* `record:related_list.dataSource` already pin — and it PROMOTES a
* pre-existing file, `ObjectCalendar.elementDataSource.test.tsx`, read end to
* end before being credited: it already drove the binding's `object` write
* onto `objectName`, `filter`/`sort` read off the named view, an unresolvable
* `view` reporting instead of fetching unfiltered, and a calendar with no
* binding behaving exactly as before. `calendar` had no single candidate that
* covered its own member set (which keys of the config object become which
* part of an event, and which of the two spellings — the nested object or the
* flat legacy fields `getCalendarConfig` falls back to — wins when a schema
* somehow carries both) — the ladder and all-day pieces of that config were
* each already pinned narrowly (`ObjectCalendar.colorFieldLadder-7243.test.tsx`,
* `ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`) but neither, nor
* anything else, asserted `startDateField`/`endDateField` reaching an event's
* `start`/`end`, `titleField` outranking the object's own display-name
* resolution, or the object-vs-flat precedence — so it is a new file,
* `objectCalendarConfigMembers-8071.test.tsx`.
*
* ⇒ The rule for every future slice of objectui#8071: delete the entry, register
* the pin, and set this constant to the new count. Not to the new count plus
* room.
*/
const MEMBER_PIN_EXEMPTION_CEILING = 48;
const MEMBER_PIN_EXEMPTION_CEILING = 46;

/**
* Every test file a member pin can live in, as LAZY `?raw` loaders.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* 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#8071 slice 5 — the member shape of `object-calendar.calendar`.
*
* The registration declares `{ name: 'calendar', type: 'object', description:
* '`startDateField`, endDateField, titleField, colorField' }` and says nothing
* about how those four names are used once inside the object — that is exactly
* what a member pin has to state (objectui#8068's criterion: constrain the
* shape the RENDERER reads, not the declaration).
*
* `getCalendarConfig` (`ObjectCalendar.tsx`) reads the whole object as one unit
* — `if (schema.calendar) return schema.calendar` — with NO merge against the
* flat legacy spelling the same function falls back to when `calendar` is
* absent. So the sharp claim this file makes, that no other file makes, is
* PRECEDENCE: an authored `calendar` object wins OUTRIGHT over a conflicting
* flat spelling on the same schema, rather than the two being merged key by
* key. `ObjectCalendar.unconfiguredRefusal-7029.test.tsx`'s last control shows
* the nested block working; it never puts a CONFLICTING flat spelling next to
* it, so it cannot show which one wins.
*
* The other two claims — that `startDateField` / `endDateField` NAME the record
* fields that become an event's `start` / `end`, and that `titleField` OUTRANKS
* the object's own default display-name resolution — are read directly off the
* emitted event objects, which nothing else in this package's suite asserts
* numerically (the 7243 ladder file and the 8026 all-day file both drive real
* renders through this same config, but assert `color`/DOM-lane placement, never
* `start/end/title` values).
*
* ⛔ NOT re-pinned here, because each already has its own file and re-testing it
* would just be a second copy to drift from the first:
* - `colorField`'s resolution ladder — `ObjectCalendar.colorFieldLadder-7243.test.tsx`.
* - `allDayField` — `ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`.
* - the required-ness of `startDateField` (no config at all → refusal screen) —
* `ObjectCalendar.unconfiguredRefusal-7029.test.tsx`.
*/

import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ObjectCalendar } from '../ObjectCalendar';

vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-detail')>()),
RecordDetailDrawer: () => null,
deriveRecordPageHref: () => null,
}));

let lastEvents: any[] = [];

vi.mock('../CalendarView', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
CalendarView: ({ events }: any) => {
lastEvents = events;
return <div data-testid="calendar-view" data-event-count={String(events.length)} />;
},
};
});

const OBJECT_SCHEMA = {
name: 'duly_task',
// The object's OWN default title pointer (ADR-0079) — deliberately a
// DIFFERENT field than the one each fixture's `titleField` names, so a title
// pin can only pass by reading the declared key, never by coincidence.
nameField: 'subject',
fields: {
id: { name: 'id', type: 'text' },
subject: { name: 'subject', type: 'text' },
nickname: { name: 'nickname', type: 'text' },
kickoff: { name: 'kickoff', type: 'datetime' },
wrapup: { name: 'wrapup', type: 'datetime' },
other_start: { name: 'other_start', type: 'datetime' },
other_title: { name: 'other_title', type: 'text' },
},
};

const ROW = {
id: 'r1',
subject: 'Default display name',
nickname: 'Authored event title',
kickoff: '2026-03-01T09:00:00.000Z',
wrapup: '2026-03-02T17:00:00.000Z',
other_start: '2099-12-31T00:00:00.000Z',
other_title: 'WRONG — from the flat spelling',
};

function makeDataSource(rows: any[] = [ROW]) {
return {
find: vi.fn(async () => ({ data: rows, total: rows.length })),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => OBJECT_SCHEMA),
} as any;
}

/** Renders `schema` and waits for the mocked `CalendarView` to receive events. */
async function eventsFor(schema: Record<string, unknown>, rows: any[] = [ROW]) {
lastEvents = [];
render(
<ObjectCalendar
schema={{ type: 'object-calendar', objectName: 'duly_task', ...schema } as any}
dataSource={makeDataSource(rows)}
/>,
);
await waitFor(() => expect(lastEvents.length).toBe(rows.length));
return lastEvents;
}

describe('object-calendar.calendar — member shape (objectui#8071 slice 5)', () => {
it('`startDateField` and `endDateField` name the record fields that become the event span', async () => {
const [event] = await eventsFor({
calendar: { startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'nickname' },
});
expect(event.start).toEqual(new Date(ROW.kickoff));
expect(event.end).toEqual(new Date(ROW.wrapup));
});

it('CONTROL: an unauthored `endDateField` leaves the event with no end at all', async () => {
const [event] = await eventsFor({
calendar: { startDateField: 'kickoff', titleField: 'nickname' },
});
expect(event.start).toEqual(new Date(ROW.kickoff));
expect(event.end).toBeUndefined();
});

it('`titleField` outranks the object\'s own default display-name field', async () => {
const [event] = await eventsFor({
calendar: { startDateField: 'kickoff', titleField: 'nickname' },
});
// `nameField: 'subject'` would resolve to ROW.subject ("Default display
// name") if `titleField` were not read first — see `resolveTitle` in
// `ObjectCalendar.tsx`.
expect(event.title).toBe(ROW.nickname);
});

it('CONTROL: with no `titleField` authored, the object\'s default display name is used', async () => {
const [event] = await eventsFor({
calendar: { startDateField: 'kickoff' },
});
expect(event.title).toBe(ROW.subject);
});

it('an authored `calendar` object wins OUTRIGHT over a conflicting flat legacy spelling', async () => {
// Both spellings are present and DISAGREE. `getCalendarConfig` returns
// `schema.calendar` unconditionally when it is set — it never merges the
// two — so every field must come from the nested object and NONE from the
// flat siblings, not even the ones the nested object leaves unset.
const [event] = await eventsFor({
calendar: { startDateField: 'kickoff', titleField: 'nickname' },
startDateField: 'other_start',
titleField: 'other_title',
endDateField: 'wrapup',
});
expect(event.start).toEqual(new Date(ROW.kickoff));
expect(event.title).toBe(ROW.nickname);
// The flat `endDateField` is NOT absorbed piecemeal: the nested object
// omits it, and the object as a whole is what wins, so the event still has
// no end — the flat sibling's `wrapup` never reaches this record.
expect(event.end).toBeUndefined();
});

it('CONTROL: with no `calendar` object authored, the flat legacy spelling is read instead', async () => {
const [event] = await eventsFor({
startDateField: 'other_start',
titleField: 'other_title',
});
expect(event.start).toEqual(new Date(ROW.other_start));
expect(event.title).toBe(ROW.other_title);
});
});
Loading