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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
46 changes: 46 additions & 0 deletions .changeset/7029-no-invented-calendar-fields.md
Original file line numberDiff line numberDiff line change
@@ -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.
141 changes: 141 additions & 0 deletions packages/app-shell/src/views/ObjectView.calendarBinding-7029.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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',
});
});
Expand All@@ -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',
});
});
Expand DownExpand Up@@ -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', () => {
Expand Down
59 changes: 51 additions & 8 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -171,6 +171,48 @@ export function timelineViewOptions(viewDef: any): Record<string, unknown> {
};
}

/**
* 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<string, unknown> | 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.
*
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`.
Expand Down
Loading
Loading