diff --git a/.changeset/11815-core-deadline-settled.md b/.changeset/11815-core-deadline-settled.md new file mode 100644 index 0000000000..c8e4309455 --- /dev/null +++ b/.changeset/11815-core-deadline-settled.md @@ -0,0 +1,5 @@ +--- +'@object-ui/core': minor +--- + +New `isDeadlineSettled(field, row, { scope, fields })` (objectui#11815). It answers whether a declared deadline is settled for one row: `true` only when the field declares `dueLike: true` and a `settledWhen` predicate that holds for the row, evaluated with `evalRowPredicate` (the row bound as `record`). A field that is not a declared deadline, a field with no `settledWhen`, and a predicate that fails to evaluate all answer `false`. Every view that draws a date cell beside a record uses it to set the cell's `deadlineSettled`. diff --git a/.changeset/11815-detail-settled-deadline.md b/.changeset/11815-detail-settled-deadline.md new file mode 100644 index 0000000000..95ed2bd135 --- /dev/null +++ b/.changeset/11815-detail-settled-deadline.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-detail': minor +--- + +The record page and related lists honour a deadline's `settledWhen` (objectui#11815). The details section, the highlights strip and each related-list row judge a declared deadline against their own record, so a done record's past due date reads neutral while an open one reads "Overdue Nd" in red. A related list asks for the fields the predicate reads when no column shows them. The deadline comes from the object field only: a `dueLike` written on a detail-view entry is ignored. diff --git a/.changeset/11815-fields-declared-overdue.md b/.changeset/11815-fields-declared-overdue.md new file mode 100644 index 0000000000..846a38d6d0 --- /dev/null +++ b/.changeset/11815-fields-declared-overdue.md @@ -0,0 +1,8 @@ +--- +'@object-ui/fields': minor +--- + +A date reads overdue only when its field declares it a deadline, and never once the deadline is settled for the record (objectui#11815). + +- **Declared only.** The `date` and `datetime` cells paint the "Overdue Nd" wording and the red colour only for a field that declares `dueLike: true`. The field-name convention that used to turn the affordance on by itself (`due_date`, `deadline`, `expires_at`, `sla_at`, …) is retired: an undeclared date in the past now reads as any past date does, for example "6 days ago". To keep the affordance on such a field, declare `dueLike: true` on it. +- **Settled.** `CellRendererProps` gains an optional `deadlineSettled` boolean. While it is `true`, both cells paint no overdue wording and no red, and read the neutral relative phrase instead. Absent or `false` changes nothing. The host computes it with `isDeadlineSettled` from `@object-ui/core`. diff --git a/.changeset/11815-grid-settled-deadline.md b/.changeset/11815-grid-settled-deadline.md new file mode 100644 index 0000000000..23efad42ab --- /dev/null +++ b/.changeset/11815-grid-settled-deadline.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-grid': minor +--- + +A done row's past deadline no longer reads overdue in the grid (objectui#11815). Every column shape — object columns, name columns, inline-row fields and the object's default columns — now carries the field's `dueLike` and `settledWhen`, and each cell is told whether its row's deadline is settled. Before, three of the four column shapes dropped `dueLike`, so a declared deadline only read overdue when its name happened to look like one. When the grid fetches its own rows, it also asks for the fields a shown deadline's `settledWhen` reads, so a grid showing only Title and Due Date still judges each row correctly. diff --git a/.changeset/11815-kanban-settled-deadline.md b/.changeset/11815-kanban-settled-deadline.md new file mode 100644 index 0000000000..e3b4b9231c --- /dev/null +++ b/.changeset/11815-kanban-settled-deadline.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-kanban': minor +--- + +A kanban card's declared deadline reads neutral once the card's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815). diff --git a/.changeset/11815-list-settled-deadline.md b/.changeset/11815-list-settled-deadline.md new file mode 100644 index 0000000000..123cef3f31 --- /dev/null +++ b/.changeset/11815-list-settled-deadline.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-list': minor +--- + +Gallery cards and the compact list honour a deadline's `settledWhen` (objectui#11815): a done record's past due date reads neutral, an open one's reads "Overdue Nd" in red. Gallery cards now also read the field's declared `dueLike`, so a declared deadline reads overdue there at all. diff --git a/.changeset/11815-tree-settled-deadline.md b/.changeset/11815-tree-settled-deadline.md new file mode 100644 index 0000000000..6b53d2b2ed --- /dev/null +++ b/.changeset/11815-tree-settled-deadline.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-tree': minor +--- + +A tree row's declared deadline reads neutral once the row's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815). diff --git a/.changeset/11815-types-view-due-like-retired.md b/.changeset/11815-types-view-due-like-retired.md new file mode 100644 index 0000000000..d7f0fdcf95 --- /dev/null +++ b/.changeset/11815-types-view-due-like-retired.md @@ -0,0 +1,5 @@ +--- +'@object-ui/types': minor +--- + +`DetailViewField.dueLike` and its `DetailViewFieldSchema` row are removed, with no alias (objectui#11815). A deadline is declared on the object's field metadata — `DateFieldMetadata.dueLike` / `DateTimeFieldMetadata.dueLike` and their partner `settledWhen`, by reference to `@objectstack/spec` — and the detail view reads it from there. A detail-view entry that still writes `dueLike` is a compile error; the zod mirror now drops the key as it drops any unknown one. Fix: delete the key from the view entry and declare `dueLike: true` on the object field. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index f858822e48..2da1057657 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1271,46 +1271,19 @@ An enhanced detail view for a single record with sections, tabs and navigation. > so author it on a record page. Placed anywhere it cannot resolve a parent id > it scopes to nothing and renders an empty list. -> **Due/deadline fields: `dueLike`.** A field entry — a `DetailViewField`, in -> `fields` or in a section's `fields` — may mark a `date` / `datetime` field as -> due/deadline-semantic. It is the same key, with the same meaning, that the -> [Date Field](/docs/fields/date) and [DateTime Field](/docs/fields/datetime) -> carry in their own object metadata: the authored entry is handed to the same -> cell renderer, so the affordance it turns on is the one described under +> **Due/deadline fields: `dueLike` and `settledWhen`.** A deadline is declared +> on the object's field metadata, never on a field entry: `dueLike` and its +> partner `settledWhen` are `@objectstack/spec`'s `FieldSchema.dueLike` and +> `FieldSchema.settledWhen`, both for `date` / `datetime` fields only. A +> `DetailViewField` entry carries neither — the detail view reads both from the +> object field it draws. See [Date Field](/docs/fields/date) and > [Overdue Affordance](/docs/fields/datetime#overdue-affordance). > -> ```json -> { -> "type": "detail-view", -> "objectName": "Contract", -> "fields": [ -> { -> "name": "end_date", -> "label": "Contract Ends", -> "type": "date", -> "format": "relative", -> "dueLike": true -> } -> ] -> } -> ``` -> -> ⚠️ `end_date` is deliberately a neutral name here, because only a declared -> `true` short-circuits: with the key absent the due/deadline field-name -> convention still decides for itself, and `dueLike: false` does not suppress -> that fallback either. Authoring a neutral name is the opt-out. -> -> ⚠️ The authored entry wins. Where the object's own field metadata also -> declares `dueLike`, that value is consulted only when the entry leaves the -> key out — it does not overwrite what the view author wrote. -> -> On the object's field metadata, `dueLike` and its partner `settledWhen` are -> `@objectstack/spec`'s `FieldSchema.dueLike` and `FieldSchema.settledWhen`, -> both for `date` / `datetime` fields only. `settledWhen` is a CEL predicate -> over the record (`record.status == 'done'`) that settles the deadline for -> that record, and the spec accepts it only beside `dueLike: true`. It is an -> object-metadata key: a `DetailViewField` entry does not carry it. ⚠️ It is -> declared, not yet rendered: no date cell evaluates it today. +> `settledWhen` is a CEL predicate over the record (`record.status == 'done'`), +> accepted only beside `dueLike: true`. The detail view evaluates it against +> the record it shows: while it holds, the deadline is settled and the cell +> paints no overdue wording or colour. Nothing is inferred from a field's +> name: an undeclared date in the past reads as any past date does. **Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema) diff --git a/content/docs/fields/date.mdx b/content/docs/fields/date.mdx index 02fabb19b0..c9c949bbef 100644 --- a/content/docs/fields/date.mdx +++ b/content/docs/fields/date.mdx @@ -37,8 +37,8 @@ const closeDate: DateFieldMetadata = { the red styling. It is `@objectstack/spec`'s `FieldSchema.dueLike`, and the spec accepts it on `date` and `datetime` fields only. `datetime` is its own type with its own metadata (`DateTimeFieldMetadata`) and honours `dueLike` identically; see -[DateTime Field](/docs/fields/datetime), which also covers the field-name fallback -today's cells still apply when the key is absent. +[DateTime Field](/docs/fields/datetime). Nothing is inferred from the field's name: +without the key, a past `due_date` reads as any past date does. `settledWhen` says when the deadline is settled for one record: a CEL predicate over the record's own columns, read as `record`. While it holds, no overdue wording or @@ -56,8 +56,16 @@ const dueDate: DateFieldMetadata = { }; ``` -⚠️ `settledWhen` is declared, not yet rendered: no date cell evaluates it today, so a -past deadline on a settled record still renders overdue. +Every view that draws the date beside its record — the grid, the record page, related +lists, kanban and gallery cards, the compact list and the tree — evaluates +`settledWhen` against that record, so a done task's past due date reads neutral while +an open one's reads overdue. A predicate that fails to evaluate leaves the deadline +unsettled. + +⚠️ The predicate reads the record as the view fetched it. A standalone grid and a +related list add the fields `settledWhen` reads to their own query; a list view +fetches its columns, so a list view that hides the field the predicate reads leaves +its deadlines unsettled. Show that field as a column. There is no date-range key. `@objectstack/spec`'s `FieldSchema` declares no date bound (its `min` / `max` are numbers) and refuses `min_date` / `max_date` by name, and no diff --git a/content/docs/fields/datetime.mdx b/content/docs/fields/datetime.mdx index 08354559d7..1c4b8c1616 100644 --- a/content/docs/fields/datetime.mdx +++ b/content/docs/fields/datetime.mdx @@ -53,13 +53,9 @@ deadline, so only a field that says so gets the affordance: - the relative face reads **"Overdue Nd"** instead of the neutral "N days ago"; - the cell is styled red, whichever display face it is painting. -The spec reads an absent key, or `false`, as "not a deadline", with nothing -inferred from the field's name. Today's cells do not follow that yet: only a -declared `true` short-circuits, and with the key absent, common due/deadline -field-name conventions (`due_at`, `deadline`, `expires_at`, `sla_at`, -`renewal_at`, `next_action_at`, …) turn the affordance on by themselves — -writing `dueLike: false` does **not** suppress that fallback. Until the fallback -is retired, authoring a neutral field name is the opt-out. +An absent key, or `false`, means "not a deadline", with nothing inferred from +the field's name: an undeclared `due_at` in the past reads as any past datetime +does. `settledWhen` says when the deadline is settled for one record. It is `FieldSchema.settledWhen`: a CEL predicate over the record's own columns, read as @@ -78,8 +74,10 @@ const respondBy: DateTimeFieldMetadata = { }; ``` -⚠️ `settledWhen` is declared, not yet rendered: no datetime cell evaluates it -today, so a past deadline on a settled record still renders overdue. +Every view that draws the datetime beside its record evaluates `settledWhen` +against that record (see [Date Field](/docs/fields/date)), and a settled deadline +reads the neutral phrase with no red. A predicate that fails to evaluate leaves +the deadline unsettled. The affordance is **day-granular**. It compares calendar days, so a datetime a few hours past its deadline still reads "Today" and is not red — the same answer diff --git a/packages/core/src/evaluator/__tests__/deadlineSettled-11815.test.ts b/packages/core/src/evaluator/__tests__/deadlineSettled-11815.test.ts new file mode 100644 index 0000000000..d84b0b6072 --- /dev/null +++ b/packages/core/src/evaluator/__tests__/deadlineSettled-11815.test.ts @@ -0,0 +1,65 @@ +/** + * 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#11815 — the ONE helper every host asks whether a declared deadline + * is settled for a row. Settled only when the field declares `dueLike: true` + * and a `settledWhen` that holds; every other case, a faulting predicate + * included, is not settled. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isDeadlineSettled } from '../deadlineSettled'; +import * as core from '../../index'; + +const DEADLINE = { name: 'due_date', dueLike: true, settledWhen: "record.status == 'done'" }; + +afterEach(() => vi.restoreAllMocks()); + +describe('isDeadlineSettled (objectui#11815)', () => { + it('is settled when the declared predicate holds for the row', () => { + expect(isDeadlineSettled(DEADLINE, { status: 'done' })).toBe(true); + }); + + it('takes the spec envelope form as well as the bare string', () => { + const envelope = { ...DEADLINE, settledWhen: { dialect: 'cel', source: "record.status == 'done'" } }; + expect(isDeadlineSettled(envelope, { status: 'done' })).toBe(true); + expect(isDeadlineSettled(envelope, { status: 'open' })).toBe(false); + }); + + it('is not settled when the predicate does not hold', () => { + expect(isDeadlineSettled(DEADLINE, { status: 'open' })).toBe(false); + }); + + it('is not settled when the field declares no settledWhen', () => { + expect(isDeadlineSettled({ name: 'due_date', dueLike: true }, { status: 'done' })).toBe(false); + }); + + it('is not settled when the field is not a declared deadline, whatever settledWhen says', () => { + expect(isDeadlineSettled({ name: 'due_date', settledWhen: DEADLINE.settledWhen }, { status: 'done' })).toBe(false); + expect(isDeadlineSettled({ ...DEADLINE, dueLike: false }, { status: 'done' })).toBe(false); + expect(isDeadlineSettled(undefined, { status: 'done' })).toBe(false); + }); + + it('is not settled when the predicate fails to evaluate', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // `status` is absent from the row: CEL faults `No such key`, never "settled". + expect(isDeadlineSettled(DEADLINE, { title: 'x' })).toBe(false); + expect(isDeadlineSettled({ ...DEADLINE, settledWhen: 'record.status ==' }, { status: 'done' })).toBe(false); + expect(isDeadlineSettled(DEADLINE, null)).toBe(false); + }); + + it('reads the host scope beside the row', () => { + const field = { ...DEADLINE, settledWhen: "record.status == 'done' && features.settle" }; + expect(isDeadlineSettled(field, { status: 'done' }, { scope: { features: { settle: true } } })).toBe(true); + expect(isDeadlineSettled(field, { status: 'done' }, { scope: { features: { settle: false } } })).toBe(false); + }); + + it('is published from @object-ui/core', () => { + expect(core.isDeadlineSettled).toBe(isDeadlineSettled); + }); +}); diff --git a/packages/core/src/evaluator/deadlineSettled.ts b/packages/core/src/evaluator/deadlineSettled.ts new file mode 100644 index 0000000000..ce776c4d40 --- /dev/null +++ b/packages/core/src/evaluator/deadlineSettled.ts @@ -0,0 +1,57 @@ +/** + * 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. + */ + +/** + * Whether a declared deadline is settled for one row (objectui#11815). + * + * `@objectstack/spec` declares the deadline semantic on a `date` / `datetime` + * field: `dueLike: true` makes it a deadline, and `settledWhen` is a per-record + * CEL predicate that, while it holds, settles that deadline for the record — + * no overdue wording, no overdue colour. The date cells paint from the field's + * declaration alone and cannot evaluate a predicate, so every host that draws + * one beside a row asks THIS function and hands the cell its answer as the + * `deadlineSettled` prop. One helper, so no host carries its own copy. + */ +import { evalRowPredicate, type RowPredicateOptions } from './listConditional.js'; +import type { FieldRulePredicate } from './fieldRules.js'; + +/** The two declared keys {@link isDeadlineSettled} reads off a field. */ +export interface DeadlineFieldLike { + name?: string; + dueLike?: boolean | null; + settledWhen?: FieldRulePredicate | null; +} + +/** + * `true` only when the field declares `dueLike: true` AND a `settledWhen`, and + * that predicate holds for `row` (bound as `record`, through + * {@link evalRowPredicate} — the evaluator, scope and relation collapse the + * grid's conditional formatting uses). + * + * Every other answer is `false`: + * - a field that is not a declared deadline, or declares no `settledWhen`, is + * never settled; + * - a predicate that fails to evaluate is NOT settled. It was judged when it + * was authored, and a quiet "settled" would hide an overdue item; the + * evaluator's own one-time warning names the fault. + */ +export function isDeadlineSettled( + field: DeadlineFieldLike | null | undefined, + row: Record | null | undefined, + opts: Pick = {}, +): boolean { + if (!field || field.dueLike !== true) return false; + const settledWhen = field.settledWhen; + if (settledWhen == null) return false; + return evalRowPredicate(settledWhen, row, { + fallback: false, + scope: opts.scope, + fields: opts.fields, + label: `${field.name ?? 'field'}.settledWhen`, + }); +} diff --git a/packages/core/src/evaluator/index.ts b/packages/core/src/evaluator/index.ts index de2b2b39ee..782ec769cd 100644 --- a/packages/core/src/evaluator/index.ts +++ b/packages/core/src/evaluator/index.ts @@ -14,6 +14,7 @@ export * from './fieldRules.js'; export * from './subjectPermissions.js'; export * from './rowPredicateCanon.js'; export * from './listConditional.js'; +export * from './deadlineSettled.js'; export * from './optionRules.js'; export * from './optionLint.js'; export * from './ExpressionCache.js'; diff --git a/packages/fields/src/__tests__/DateCellRenderer.test.tsx b/packages/fields/src/__tests__/DateCellRenderer.test.tsx index 6105ed0470..62116bd01f 100644 --- a/packages/fields/src/__tests__/DateCellRenderer.test.tsx +++ b/packages/fields/src/__tests__/DateCellRenderer.test.tsx @@ -68,10 +68,19 @@ describe('DateCellRenderer', () => { expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument(); }); - it('labels a past due_date as "Overdue Nd" and colors it red', () => { + // CONTROL (objectui#11815, ruling D): this pin used to assert the field-name + // guess — an undeclared `due_date` in the past read "Overdue 6d" in red. + // Overdue is declared-only now, so the same field reads as any past date. + it('CONTROL — an undeclared past due_date reads neutral, with no red', () => { render(); + const el = screen.getByText('6 days ago'); + expect(el.className).not.toMatch(/text-red-600/); + expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument(); + }); + + it('labels a past DECLARED deadline as "Overdue Nd" and colors it red', () => { + render(); const el = screen.getByText('Overdue 6d'); - expect(el).toBeInTheDocument(); expect(el.className).toMatch(/text-red-600/); }); diff --git a/packages/fields/src/__tests__/date-locale-channel.test.tsx b/packages/fields/src/__tests__/date-locale-channel.test.tsx index 3a950ff911..1d6de32560 100644 --- a/packages/fields/src/__tests__/date-locale-channel.test.tsx +++ b/packages/fields/src/__tests__/date-locale-channel.test.tsx @@ -348,14 +348,15 @@ describe('en session — output is byte-identical (must-not-change)', () => { describe('the already-localized overdue branch is undisturbed (green both sides)', () => { it('resolves through the translate fn, not through Intl — zh', () => { - renderSession('zh', ); + // Declared (objectui#11815): overdue is declared-only, the name guesses nothing. + renderSession('zh', ); const el = screen.getByText('逾期 6 天'); expect(el).toBeInTheDocument(); expect(el.className).toMatch(/text-red-600/); }); it('resolves through the translate fn, not through Intl — en', () => { - renderSession('en', ); + renderSession('en', ); expect(screen.getByText('Overdue 6d')).toBeInTheDocument(); }); }); diff --git a/packages/fields/src/__tests__/dateCell.overdueZone-10183.test.tsx b/packages/fields/src/__tests__/dateCell.overdueZone-10183.test.tsx index 33246c3870..baa5bed02e 100644 --- a/packages/fields/src/__tests__/dateCell.overdueZone-10183.test.tsx +++ b/packages/fields/src/__tests__/dateCell.overdueZone-10183.test.tsx @@ -74,7 +74,8 @@ afterEach(() => { function cellAt(value: string): { text: string; red: boolean } { const { container } = render( - , + // Declared (objectui#11815): overdue is declared-only, the name guesses nothing. + , ); const span = container.querySelector('span'); return { text: span?.textContent ?? '', red: (span?.className ?? '').includes('text-red-600') }; diff --git a/packages/fields/src/__tests__/dateCell.unparsableValue-8581.test.tsx b/packages/fields/src/__tests__/dateCell.unparsableValue-8581.test.tsx index 91cac1ae4b..5adb9c2632 100644 --- a/packages/fields/src/__tests__/dateCell.unparsableValue-8581.test.tsx +++ b/packages/fields/src/__tests__/dateCell.unparsableValue-8581.test.tsx @@ -226,7 +226,8 @@ describe('objectui#8581 — an unparsable date string is not a formatted value, it('POPULATED — a past due_date is still "Overdue Nd" in red', () => { const past = new Date(); past.setDate(past.getDate() - 6); - const { container } = renderCell('date', past.toISOString(), { name: 'due_date' }); + // Declared (objectui#11815): overdue is declared-only, the name guesses nothing. + const { container } = renderCell('date', past.toISOString(), { name: 'due_date', dueLike: true }); const span = container.querySelector('span.tabular-nums'); expect(span?.textContent, 'due_date: a real past date must still read Overdue').toMatch(/Overdue/); expect(span?.className, 'due_date: a real past date must still be red').toMatch(/text-red-600/); diff --git a/packages/fields/src/__tests__/dateCells.deadlineSettled-11815.test.tsx b/packages/fields/src/__tests__/dateCells.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..75da886b29 --- /dev/null +++ b/packages/fields/src/__tests__/dateCells.deadlineSettled-11815.test.tsx @@ -0,0 +1,98 @@ +/** + * 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#11815 — overdue is declared-only (ruling D). + * + * A date or datetime reads overdue only when its field declares + * `dueLike: true`, and never while the host says the deadline is settled for + * the row (`deadlineSettled`, the verdict of `isDeadlineSettled` in + * `@object-ui/core`). Settled reads exactly as a non-deadline: the neutral + * relative phrase, no red. The field-name guess is retired, so an undeclared + * `due_date` in the past is neutral too. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { DateCellRenderer, DateTimeCellRenderer } from '../index'; + +afterEach(() => cleanup()); + +const daysFromNow = (n: number) => { + const d = new Date(); + d.setDate(d.getDate() + n); + return d.toISOString(); +}; + +const face = (el: HTMLElement) => { + const span = el.querySelector('span.tabular-nums') as HTMLElement | null; + return { text: span?.textContent ?? '', red: !!span?.className.includes('text-red-600') }; +}; + +const DUE_DATE = { type: 'date', name: 'due_date', dueLike: true, settledWhen: "record.status == 'done'" } as any; +const DUE_DATETIME = { type: 'datetime', name: 'due_at', dueLike: true, format: 'relative', settledWhen: "record.status == 'done'" } as any; + +describe('date cell (objectui#11815)', () => { + it('a declared deadline in the past reads "Overdue Nd" in red', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: 'Overdue 6d', red: true }); + }); + + it('the same deadline, settled for the row, reads neutral with no red', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: '6 days ago', red: false }); + }); + + it('deadlineSettled={false} changes nothing', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: 'Overdue 6d', red: true }); + }); + + it('CONTROL — an undeclared due_date in the past reads neutral', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: '6 days ago', red: false }); + }); + + it('CONTROL — a declared deadline in the future is unchanged', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: 'In 3 days', red: false }); + }); +}); + +describe('datetime cell (objectui#11815)', () => { + it('a declared deadline in the past reads "Overdue Nd" in red', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: 'Overdue 6d', red: true }); + }); + + it('the same deadline, settled for the row, reads neutral with no red', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: '6 days ago', red: false }); + }); + + it('settled on the compact face, the default, paints no red either', () => { + const compact = { ...DUE_DATETIME, format: undefined }; + const open = render(); + expect(face(open.container).red).toBe(true); + cleanup(); + const settled = render(); + expect(face(settled.container).red).toBe(false); + }); + + it('CONTROL — an undeclared due_at in the past reads neutral', () => { + const { container } = render( + , + ); + expect(face(container)).toEqual({ text: '6 days ago', red: false }); + }); + + it('CONTROL — a declared deadline in the future is unchanged', () => { + const { container } = render(); + expect(face(container)).toEqual({ text: 'In 3 days', red: false }); + }); +}); diff --git a/packages/fields/src/__tests__/datetimeCell.dueLikeAffordance-8958.test.tsx b/packages/fields/src/__tests__/datetimeCell.dueLikeAffordance-8958.test.tsx index c307c3726f..74ac12cf06 100644 --- a/packages/fields/src/__tests__/datetimeCell.dueLikeAffordance-8958.test.tsx +++ b/packages/fields/src/__tests__/datetimeCell.dueLikeAffordance-8958.test.tsx @@ -242,18 +242,19 @@ describe('DAY GRANULARITY — inherited from the shared relative path, not re-de }); describe('THE OTHER SURFACES the one key reaches', () => { - it('the field-name heuristic answers the same on both cells, with no key authored', () => { - // `due_at` matches the due/deadline name convention, so the affordance - // appears with nothing authored at all. This is the larger population the - // change moves, and it is pinned rather than left implicit. + it('CONTROL — with no key authored, a deadline-shaped name paints nothing on either cell', () => { + // Inverted by objectui#11815 (ruling D). This pin used to assert the + // field-name heuristic: `due_at` read "Overdue 3d" in red with nothing + // authored. The heuristic is retired — overdue is declared-only — so the + // same name now reads exactly as a neutral one, on both cells. const dateTime = faceOf(dateTimeCell(OVERDUE_3D, { name: 'due_at', format: 'relative' })); const date = faceOf(dateCell(OVERDUE_3D, { name: 'due_at', format: 'relative' })); - expect(dateTime.text).toBe('Overdue 3d'); + expect(dateTime.text).toBe('3 days ago'); expect(dateTime.text).toBe(date.text); - expect(dateTime.red).toBe(date.red); + expect(dateTime.red).toBe(false); + expect(date.red).toBe(false); - // A neutral name on the same instant stays neutral — the heuristic is a - // name test, not "every past datetime". + // A neutral name on the same instant reads the same. const neutral = faceOf(dateTimeCell(OVERDUE_3D, { name: 'created_at', format: 'relative' })); expect(neutral.text).toBe('3 days ago'); expect(neutral.red).toBe(false); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index bc5716bcb1..34d90ee28f 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -388,6 +388,17 @@ export interface CellRendererProps { * that does not pass it changes nothing. */ dependentValues?: Record; + /** + * The host's answer to "is this declared deadline settled for this row?" + * (objectui#11815) — `isDeadlineSettled` from `@object-ui/core`, which + * evaluates the field's `settledWhen` against the row the host holds. A cell + * cannot evaluate a predicate, so the host hands it the verdict. + * + * While it is `true` the `date` and `datetime` cells paint no overdue wording + * and no overdue colour: the relative wording falls back to the neutral + * phrase a non-deadline reads. Absent or `false` changes nothing. + */ + deadlineSettled?: boolean; } // `coerceToSafeValue` lives in `./coerceToSafeValue.ts` (objectui#8580) so @@ -1044,10 +1055,12 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React. * The overdue affordance — ONE home for both date-family cell renderers * (objectui#8958). * - * `dueLike` is declared for BOTH types: `DetailViewFieldSchema.dueLike` - * (`@object-ui/types`' zod views) says, in the `describe` text an author - * reads, "Marks a date/datetime field as due/deadline-semantic, gating the - * relative 'Overdue Nd' wording". `DateCellRenderer` honoured it; + * `dueLike` is declared for BOTH types — today on the field itself + * (`DateFieldMetadata.dueLike` / `DateTimeFieldMetadata.dueLike`, by reference + * to `@objectstack/spec`); when this was written, on the since-retired view + * entry key, whose `describe` text read "Marks a date/datetime field as + * due/deadline-semantic, gating the relative 'Overdue Nd' wording". + * `DateCellRenderer` honoured it; * `DateTimeCellRenderer` never read it, so an author who marked a `datetime` * column `dueLike` published successfully and the affordance simply did not * appear — accepted, parsed, dropped, and rendered as a legitimate-looking @@ -1059,40 +1072,35 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React. * datetime no key at all -> "3 days ago" + no red <- byte-identical * * These two functions exist so the repair is a SHARED read rather than a - * second copy. The regex and the midnight predicate below were inline in - * `DateCellRenderer`; copying either into the sibling is objectui#4576 - * exactly — the shape this package already paid for when one convention was - * duplicated across a boundary and the two copies drifted while both stayed - * "correct". The wording half was already shared (both cells reach the same + * second copy: copying either into the sibling is objectui#4576 exactly — the + * shape this package already paid for when one convention was duplicated + * across a boundary and the two copies drifted while both stayed "correct". + * The wording half was already shared (both cells reach the same * `formatRelativeDate`, which reads `options.dueLike`); these cover the two * halves that were not. */ -const DUE_LIKE_FIELD_NAME = - /(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/; /** - * Whether a field is due/deadline-semantic: the authored key first, then the - * field-name convention. - * - * A date is only *semantically* a due/deadline when the field says so — a - * plain "start_date" or "created_at" in the past is neither overdue text nor - * red, even though it renders in the same relative-time style. - * - * `dueLike` is read through the two interfaces that DECLARE it rather than - * through `as any`, which is the objectui#7747 discipline the `format` read - * one function down already follows: `as any` would also silence a typo in - * the property name, this does not. The name spellings stay on a loose record - * read because `accessorKey` / `key` are grid-column spellings that no field - * interface carries — and that read goes through `unknown`, because - * `FieldMetadata` is a closed union whose members carry no index signature, - * so a direct assertion is `TS2352` (measured, not assumed). + * Whether a field is due/deadline-semantic, for ONE row: declared, and not + * settled (objectui#11815, ruling D: overdue is declared-only). + * + * A date is a deadline only when its field declares `dueLike: true` — + * `@objectstack/spec`'s `FieldSchema.dueLike`, typed by reference on + * `DateFieldMetadata` / `DateTimeFieldMetadata`. Nothing else is read. The + * field-name convention that used to stand behind the declaration (a regex + * over `due` / `deadline` / `expires` / …) is retired: under ADR-0049 nothing + * undeclared paints, so an undeclared `due_date` in the past reads as any + * past date does. + * + * `deadlineSettled` is the host's verdict on the field's `settledWhen` for + * this row (`isDeadlineSettled` in `@object-ui/core`). While it holds, the + * cell answers exactly as for a non-deadline: neutral wording, no red. It is + * a separate input on purpose — ⛔ never carried as `dueLike: false`, which + * would merge "settled" with "not a deadline". */ -function resolveDueLike(field: CellRendererProps['field']): boolean { - const declared = (field as DateFieldMetadata | DateTimeFieldMetadata | undefined)?.dueLike; - if (declared === true) return true; - const named = field as unknown as Record | undefined; - const fieldName = String(named?.name || named?.accessorKey || named?.key || '').toLowerCase(); - return DUE_LIKE_FIELD_NAME.test(fieldName); +function resolveDueLike(field: CellRendererProps['field'], deadlineSettled?: boolean): boolean { + if (deadlineSettled === true) return false; + return (field as DateFieldMetadata | DateTimeFieldMetadata | undefined)?.dueLike === true; } /** @@ -1119,7 +1127,7 @@ function isOverdueInstant(date: Date, dueLike: boolean): boolean { /** * Date field cell renderer */ -export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement { +export function DateCellRenderer({ value, field, deadlineSettled }: CellRendererProps): React.ReactElement { // `useDisplayLocale()`, NOT `useLocalization().locale` (objectui#4468). The // raw tenant locale is `undefined` on any workspace that never configured // one, and `Intl` reads the MACHINE's locale from `undefined` — so a `zh` @@ -1185,7 +1193,7 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea // Both halves of the affordance come from the shared reads above, so the // `datetime` sibling one function down answers this question identically // instead of carrying a second copy (objectui#8958). - const dueLike = resolveDueLike(field); + const dueLike = resolveDueLike(field, deadlineSettled); const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t }); const isOverdue = isOverdueInstant(displayDate, dueLike); @@ -1202,7 +1210,7 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea /** * DateTime field cell renderer (Airtable-style with date and time visually separated) */ -export function DateTimeCellRenderer({ value, field }: CellRendererProps): React.ReactElement { +export function DateTimeCellRenderer({ value, field, deadlineSettled }: CellRendererProps): React.ReactElement { // Hook before every early return — a value flipping between null and set // must not change the hook count between renders (same rule as the number / // currency renderers above). This is the site objectui#4468 caught rendering @@ -1306,7 +1314,7 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React // narrowed rather than closed. Since `'compact'` is THIS cell's default // face, that is also where the visible population is. const style = authoredFormat === 'short' ? 'compact' : authoredFormat; - const dueLike = resolveDueLike(field); + const dueLike = resolveDueLike(field, deadlineSettled); const isOverdue = isOverdueInstant(date, dueLike); // Spelled as the sibling spells it, one function up, for the same reason // every other guard in these two renderers is: one shape, one reading. diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 4b36e8133f..d19f181ddc 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -26,8 +26,8 @@ import { LazyIcon, } from '@object-ui/components'; import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react'; -import { SchemaRenderer, toRenderableSchema, useInlineEdit, usePrintPage } from '@object-ui/react'; -import { resolveFieldAffordance } from '@object-ui/core'; +import { SchemaRenderer, toRenderableSchema, useInlineEdit, usePrintPage, usePredicateScope } from '@object-ui/react'; +import { resolveFieldAffordance, isDeadlineSettled } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { getCellRenderer, @@ -207,6 +207,11 @@ export const DetailSection: React.FC = ({ * the question is not asked, so a bare `DetailSection` behaves as before. */ const perms = usePermissions(); + /** + * The host predicate scope a declared deadline's `settledWhen` is judged in + * beside this record (objectui#11815) — the scope the grid reads too. + */ + const predicateScope = usePredicateScope(); /** * What the copy affordance WRITES (objectui#8395). @@ -540,7 +545,13 @@ export const DetailSection: React.FC = ({ if (resolvedType) { const CellRenderer = getCellRenderer(resolvedType); if (CellRenderer) { - return ; + return ( + + ); } } return String(value); diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 391997db42..bc5df2a429 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -17,10 +17,10 @@ import { TooltipTrigger, } from '@object-ui/components'; import type { HighlightField } from '@object-ui/types'; -import { EXPANDABLE_FIELD_TYPES, resolveFieldAffordance } from '@object-ui/core'; +import { EXPANDABLE_FIELD_TYPES, resolveFieldAffordance, isDeadlineSettled } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { getCellRenderer, resolveCellRendererType, useBooleanValueLabel } from '@object-ui/fields'; -import { useSafeFieldLabel, useInlineEdit } from '@object-ui/react'; +import { useSafeFieldLabel, useInlineEdit, usePredicateScope } from '@object-ui/react'; import { Check, X, Pencil } from 'lucide-react'; import { InlineFieldInput } from './InlineFieldInput'; import { @@ -67,6 +67,9 @@ export const HeaderHighlight: React.FC = ({ // The caller's permissions, for the field question the strip asks of each // highlight — the same one the details body asks (objectui#12103). const perms = usePermissions(); + // The host predicate scope a declared deadline's `settledWhen` is judged in + // (objectui#11815), against the staged record below. + const predicateScope = usePredicateScope(); // The STAGED record: the saved record this strip receives, overlaid with the // edit session's draft by the same spread `DetailView` gives the details body @@ -413,7 +416,11 @@ export const HeaderHighlight: React.FC = ({ isKpi && 'tabular-nums tracking-tight', )} > - + )} diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 4e5ed754cf..ee97314db0 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -29,7 +29,7 @@ import { resolveIcon, useIsMobile, } from '@object-ui/components'; -import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, useFilterScope, useResolvedFilter, type RelatedRowActionDef } from '@object-ui/react'; +import { SchemaRenderer, useCapabilityGate, useCondition, toPredicateInput, useSettledSchema, useFilterScope, useResolvedFilter, usePredicateScope, type RelatedRowActionDef } from '@object-ui/react'; import { Plus, ExternalLink, @@ -66,6 +66,7 @@ import { toFilterNodeSafely, userActionPredicates, resolveAffordance, + isDeadlineSettled, type FilterNode, } from '@object-ui/core'; import { useSafeFieldLabel } from '@object-ui/react'; @@ -575,6 +576,9 @@ export const RelatedList: React.FC = ({ * `RelatedList.multiValueParentScope-7299.test.tsx`. */ const { ready: objectSchemaReady, def: objectSchema } = useSettledSchema(api ?? '', dataSource); + // The host predicate scope a child row's declared deadline is judged in + // (objectui#11815) — the scope the grid's cells read. + const predicateScope = usePredicateScope(); const [collapsed, setCollapsed] = React.useState(defaultCollapsed); // Add-by-picker (generic m2m/junction assignment). `refreshNonce` re-runs the // auto-fetch after an add/remove so the list reflects the new link rows. @@ -863,22 +867,33 @@ export const RelatedList: React.FC = ({ if (authored.length === 0) return undefined; const relatedObjectName = objectName || api || ''; const projection = new Set(['id']); + const drawnKeys: string[] = []; for (const col of keepReadableColumns(dropParentKeyColumn(authored, referenceField), perms, relatedObjectName)) { const key = drawnColumnKey(col); - if (key) projection.add(key); + if (key) { + projection.add(key); + drawnKeys.push(key); + } } for (const root of expandFields) projection.add(root); const fields = objectSchema?.fields; if (fields && typeof fields === 'object') { const readable = (field: string): boolean => !perms?.isLoaded || !relatedObjectName || perms.checkField(relatedObjectName, field, 'read'); - const operands = collectPredicateFieldRefs( - listViewPredicates({ + // A drawn declared deadline's `settledWhen` (objectui#11815) reads the + // row like a row predicate does, so its operands ride the same gates. + const settledWhens = drawnKeys + .map((key) => parentRelationshipFieldDef(fields, key) as { dueLike?: unknown; settledWhen?: unknown } | undefined) + .filter((def) => def?.dueLike === true && def.settledWhen != null) + .map((def) => def!.settledWhen); + const operands = collectPredicateFieldRefs([ + ...listViewPredicates({ rowActionDefs: rowActions, objectActions: objectSchema?.actions, userActions: objectSchema?.userActions, }), - ); + ...settledWhens, + ]); for (const field of operands) { // Declared — looked up in EITHER container shape the metadata API // serves (the Record, or the array of named defs), because a reader @@ -1501,7 +1516,7 @@ export const RelatedList: React.FC = ({ // values, etc. Shared by BOTH the explicit-columns path and the // auto-derived path so a `status` column reads "Planned" (badge), never the // raw `planned`, regardless of how the columns were supplied. - const makeCell = (key: string, def: any): ((value: any) => any) | undefined => { + const makeCell = (key: string, def: any): ((value: any, row?: Record) => any) | undefined => { // ⛔ A column whose field carries no `type` gets NO cell — and therefore // none of the objectui#8459 placeholder below. That boundary is // DELIBERATE, and objectui#8477 is the card that measured it and chose to @@ -1616,6 +1631,9 @@ export const RelatedList: React.FC = ({ // read `5000%` in this list (objectui#11475). ...((def as any).max !== undefined && { max: (def as any).max }), ...(def.format && { format: def.format }), + // The FIELD's deadline declaration (objectui#11815): without it a + // child row's declared deadline never reads overdue in this list. + ...(def.dueLike !== undefined && { dueLike: def.dueLike }), // objectui#6837 half 2 narrowed the READ to `reference` (the only // spelling the protocol declares — `FieldSchema` refuses `reference_to` // by name), and objectui#11070 round 4 moved the EMITTED key with it: @@ -1625,7 +1643,7 @@ export const RelatedList: React.FC = ({ ...(def.reference && { reference: def.reference }), ...(def.reference_field && { reference_field: def.reference_field }), }; - return (value: any) => { + return (value: any, row?: Record) => { // ONE definition of emptiness with `pruneEmpty` — see `isValueEmpty`. // This used to test `null | undefined` alone, which let a // whitespace-only string or an empty array through to a renderer that @@ -1636,6 +1654,9 @@ export const RelatedList: React.FC = ({ return React.createElement(CellRenderer, { value: resolveUserValue(value), field: fieldMeta, + // The CHILD row's own `settledWhen` verdict (objectui#11815), judged + // against the row the table hands this cell — never the parent record. + deadlineSettled: isDeadlineSettled(def, row, { scope: predicateScope, fields: objectSchema?.fields }), }); }; }; @@ -1842,7 +1863,7 @@ export const RelatedList: React.FC = ({ const pruned = pruneEmpty(generated); return pruned.slice(0, Math.max(1, maxColumns)); - }, [columns, objectSchema, objectName, api, resolveFieldLabel, referenceField, relatedData, maxColumns, lookupLabels, perms, redactedFields]); + }, [columns, objectSchema, objectName, api, resolveFieldLabel, referenceField, relatedData, maxColumns, lookupLabels, perms, redactedFields, predicateScope]); /** * [#6108] The SERVED per-column sortability projection for this object — diff --git a/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx index 934df4d085..a2dac44ca8 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx @@ -7,7 +7,8 @@ */ /** - * An authored `dueLike` on a DETAIL-VIEW FIELD reaches the cell (objectui#9729). + * An authored `dueLike` on a DETAIL-VIEW FIELD reaches the cell (objectui#9729) + * — retired by objectui#11815; the cases at the bottom pin the inversion. * * ## Why this reading exists * @@ -61,9 +62,23 @@ const sectionOf = (fields: unknown[]): DetailViewSection => const BASE = { name: 'end_date', label: 'End', type: 'date', format: 'relative' }; const DATA = { end_date: daysAgo(3) }; -describe('an authored `dueLike` on a DetailViewField', () => { - it('reaches the date cell and changes the drawn wording', () => { +/** + * ⭐ INVERTED BY RULING (objectui#11815, ruling D) — ⛔ not relaxed. The + * authored view-entry `dueLike` retired: the deadline is the object FIELD's + * declaration, read through `enrichDetailField`. So the key on the view entry + * no longer reaches the cell, and the same key on the object field does. + */ +describe('`dueLike` reaches the detail cell from the FIELD, not the view entry (objectui#11815)', () => { + it('a view-entry `dueLike` no longer changes the drawn wording', () => { render(); + const html = document.body.innerHTML; + expect(html).toContain('days ago'); + expect(html).not.toContain('Overdue'); + }); + + it("the object field's `dueLike` reaches the date cell and changes the drawn wording", () => { + const objectSchema = { fields: { end_date: { name: 'end_date', type: 'date', dueLike: true } } }; + render(); expect(document.body.innerHTML).toContain('Overdue'); }); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..cc98dd65dc --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx @@ -0,0 +1,102 @@ +/** + * 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#11815 — a related list judges each CHILD row's declared deadline + * against that row (never the parent record), and asks the server for the + * fields its `settledWhen` reads when no column shows them. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { RelatedList } from '../RelatedList'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +/** Capture the schema `RelatedList` hands to `SchemaRenderer` (the data-table). */ +const h = vi.hoisted(() => ({ schema: null as any })); +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + SchemaRenderer: (props: any) => { + h.schema = props.schema; + return null; + }, + }; +}); + +const OBJECT = 'task'; +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const FIELDS = { + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + project_id: { type: 'text', label: 'Project' }, + due_date: { type: 'date', label: 'Due', dueLike: true, settledWhen: "record.status == 'done'" }, +}; +const ROWS = [ + { id: 't1', title: 'Open', status: 'open', due_date: daysAgo(5), project_id: 'p1' }, + { id: 't2', title: 'Done', status: 'done', due_date: daysAgo(5), project_id: 'p1' }, +]; + +const makeDataSource = () => ({ + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: FIELDS })), + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), +}); + +beforeEach(() => { + h.schema = null; +}); +afterEach(cleanup); + +const renderList = (ds: ReturnType) => + render( + , + ); + +const drawCell = (row: Record) => { + const col = (h.schema?.columns ?? []).find((c: any) => c.accessorKey === 'due_date'); + const { container } = render(<>{col.cell(row.due_date, row)}); + const span = container.querySelector('span.tabular-nums') as HTMLElement | null; + return { text: span?.textContent ?? '', red: !!span?.className.includes('text-red-600') }; +}; + +describe('RelatedList — each child row is judged on its own (objectui#11815)', () => { + it("an open row's declared deadline reads overdue, a done row's reads neutral", async () => { + const ds = makeDataSource(); + renderList(ds); + await waitFor(() => expect((h.schema?.columns ?? []).some((c: any) => c.accessorKey === 'due_date')).toBe(true)); + expect(drawCell(ROWS[0])).toEqual({ text: 'Overdue 5d', red: true }); + expect(drawCell(ROWS[1])).toEqual({ text: '5 days ago', red: false }); + }); + + it("asks for the settledWhen's operand when no column shows it", async () => { + const ds = makeDataSource(); + renderList(ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + const selects = ds.find.mock.calls.filter(([api]: any[]) => api === OBJECT).map(([, p]: any[]) => p?.$select); + expect(selects.length).toBeGreaterThan(0); + for (const sel of selects) expect(sel).toEqual(expect.arrayContaining(['id', 'title', 'due_date', 'status'])); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/recordPage.deadlineSettled-11815.test.tsx b/packages/plugin-detail/src/__tests__/recordPage.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..4c3159de72 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/recordPage.deadlineSettled-11815.test.tsx @@ -0,0 +1,96 @@ +/** + * 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#11815 — the record page judges a declared deadline's `settledWhen` + * against its record and hands the cell `deadlineSettled` (ruling D). + * + * Through the real `DetailSection` and `HeaderHighlight`: a done record's past + * declared deadline reads neutral, an open record's reads "Overdue Nd" in red. + */ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { DetailSection } from '../DetailSection'; +import { HeaderHighlight } from '../HeaderHighlight'; +import type { DetailViewSection } from '@object-ui/types'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const OBJECT_SCHEMA = { + name: 'task', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + status: { name: 'status', type: 'text', label: 'Status' }, + due_date: { + name: 'due_date', + type: 'date', + label: 'Due', + dueLike: true, + settledWhen: "record.status == 'done'", + }, + }, +}; + +const SECTION = { title: 'S', fields: [{ name: 'due_date', label: 'Due' }] } as unknown as DetailViewSection; + +const dueSpan = (container: HTMLElement) => { + const span = container.querySelector('span.tabular-nums') as HTMLElement | null; + return { text: span?.textContent ?? '', red: !!span?.className.includes('text-red-600') }; +}; + +describe('DetailSection — a declared deadline is judged against the record (objectui#11815)', () => { + it('an open record past its deadline reads overdue in red', () => { + const { container } = render( + , + ); + expect(dueSpan(container)).toEqual({ text: 'Overdue 5d', red: true }); + }); + + it('a done record past the same deadline reads neutral, with no red', () => { + const { container } = render( + , + ); + expect(dueSpan(container)).toEqual({ text: '5 days ago', red: false }); + }); + + it('CONTROL — an undeclared due_date past its day reads neutral on an open record', () => { + const undeclared = { ...OBJECT_SCHEMA, fields: { ...OBJECT_SCHEMA.fields, due_date: { name: 'due_date', type: 'date' } } }; + const { container } = render( + , + ); + expect(dueSpan(container)).toEqual({ text: '5 days ago', red: false }); + }); +}); + +describe('HeaderHighlight — the same judgement on the highlights strip (objectui#11815)', () => { + const FIELDS = [{ name: 'due_date', label: 'Due' }]; + + it('an open record past its deadline reads overdue in red', () => { + const { container } = render( + , + ); + expect(dueSpan(container)).toEqual({ text: 'Overdue 5d', red: true }); + }); + + it('a done record past the same deadline reads neutral, with no red', () => { + const { container } = render( + , + ); + expect(dueSpan(container)).toEqual({ text: '5 days ago', red: false }); + }); +}); diff --git a/packages/plugin-detail/src/fieldEnrichment.ts b/packages/plugin-detail/src/fieldEnrichment.ts index 04cfa1e1da..7e221fd04f 100644 --- a/packages/plugin-detail/src/fieldEnrichment.ts +++ b/packages/plugin-detail/src/fieldEnrichment.ts @@ -229,7 +229,11 @@ export const ENRICHED_FIELD_METADATA_KEYS = [ // Per-field widget override (ADR-0056 P2) — a structured editor replaces the // raw type in inline edit too, matching the form path. 'widget', + // The deadline semantic (objectui#11815): the FIELD's `dueLike`, and the + // `settledWhen` the host judges against the record before it hands the cell + // `deadlineSettled`. See `DEADLINE_KEYS` in `enrichDetailField` below. 'dueLike', + 'settledWhen', // Relational / picker configuration. Every key the lookup + user pickers read // off their field metadata, so an inline-edit picker behaves @@ -261,11 +265,15 @@ export const ENRICHED_FIELD_METADATA_KEYS = [ 'dependsOn', ] as const; +/** The two deadline keys only the object field may supply (objectui#11815). */ +const DEADLINE_KEYS = ['dueLike', 'settledWhen'] as const; + /** * Merge a detail-view field with its object-schema metadata. * * The view field always wins — it is the author's explicit override; the object - * schema only fills the keys it left undefined. Shared by `DetailSection` (the + * schema only fills the keys it left undefined — except the two deadline keys, + * which only the object field supplies. Shared by `DetailSection` (the * details body) and `HeaderHighlight` (the highlights strip) so the two editors * can't drift again. * @@ -278,6 +286,10 @@ export function enrichDetailField( objectDefField: Record | undefined | null, ): Record { const enriched: Record = { ...viewField }; + // The deadline is the FIELD's declaration and nothing else's (objectui#11815, + // ruling D): the authored view-entry `dueLike` retired, so a view entry + // never supplies either deadline key — not even one that arrives untyped. + for (const key of DEADLINE_KEYS) delete enriched[key]; if (!objectDefField) return enriched; if (!enriched.type && objectDefField.type) enriched.type = objectDefField.type; diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 81725cb49f..91e94720b2 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -61,7 +61,7 @@ import { toast, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, resolveAffordance, resolveFieldAffordance, type SchemaLike, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, type FilterTokenScope } from '@object-ui/core'; +import { resolveConditionalFormatting, isDeadlineSettled, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, resolveAffordance, resolveFieldAffordance, type SchemaLike, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, type FilterTokenScope } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -2820,9 +2820,26 @@ export const ObjectGrid: React.FC = ({ .filter((f) => isProjectableField(f, declared as Record)) .filter((f) => passesProjectionGate(f)) : []; + // [objectui#11815] A shown DECLARED deadline's `settledWhen` + // operands. The predicate reads the row exactly as a row action's + // gate does, so a grid showing only Title and Due fetched rows + // without `status`, CEL faulted `No such key`, and a done row's + // deadline still read overdue. The same two gates as the harvests + // above. + const shownColumns = (schemaFields ?? (Array.isArray(schemaColumns) ? schemaColumns : [])) as unknown[]; + const deadlineFields = declared + ? collectPredicateFieldRefs( + shownColumns + .map((entry) => (declared as Record)[columnIdentity(entry) ?? '']) + .filter((def) => def?.dueLike === true && def.settledWhen != null) + .map((def) => def!.settledWhen), + ) + .filter((f) => isProjectableField(f, declared as Record)) + .filter((f) => passesProjectionGate(f)) + : []; // A UNION, never an append: a grouping field that IS also a column // must not produce a duplicate `$select` entry. - const extraFields = [...predicateFields, ...groupingFields, ...concurrencyFields, ...rowColorFields]; + const extraFields = [...predicateFields, ...groupingFields, ...concurrencyFields, ...rowColorFields, ...deadlineFields]; const withHarvestedFields = (list: any[]): any[] => { if (extraFields.length === 0) return list; const names = new Set(list.map((f: any) => columnIdentity(f))); @@ -3592,6 +3609,13 @@ export const ObjectGrid: React.FC = ({ }, [pageSizeDiagnosticObject, pageSizeDiagnosticFlat, columnDiagnosticBlockType, schema.objectName]); const generateColumns = useCallback((): ObjectGridColumnDraft[] => { + // objectui#11815 — the host's verdict on a declared deadline's + // `settledWhen` for ONE row, handed to the cell as `deadlineSettled`. The + // one helper in `@object-ui/core`, with the scope and relation collapse + // this grid's conditional formatting uses; a field that is not a declared + // deadline answers `false` before any predicate runs. + const settledFor = (field: unknown, row: Record | undefined): boolean => + isDeadlineSettled(field as Parameters[0], row, { scope: predicateScope, fields: objectFields }); // Map field type to column header icon (Airtable-style) const getTypeIcon = (fieldType: string | null): React.ReactNode => { if (!fieldType) return ; @@ -3872,6 +3896,12 @@ export const ObjectGrid: React.FC = ({ // read `5000%` in this cell. if (objectDefField.max !== undefined) fieldMeta.max = objectDefField.max; if (objectDefField.format) fieldMeta.format = objectDefField.format; + // objectui#11815 — the deadline declaration rides on every + // builder: `dueLike` is what the date cells paint from, and + // `settledWhen` is what `isDeadlineSettled` judges per row. + // Dropped here, a declared deadline never read overdue. + if (objectDefField.dueLike !== undefined) fieldMeta.dueLike = objectDefField.dueLike; + if (objectDefField.settledWhen !== undefined) fieldMeta.settledWhen = objectDefField.settledWhen; if (objectDefField.options) fieldMeta.options = translateOptions(schema.objectName, col.field, objectDefField.options); } // Preserve relational metadata (reference, displayField, …) so @@ -3906,7 +3936,7 @@ export const ObjectGrid: React.FC = ({ // Both link and action: link takes priority for navigation, action executes on secondary interaction cellRenderer = (value: any, row: any) => { const displayContent = pointerFace?.(row) ?? (LinkContentRenderer - ? + ? : (value != null && value !== '' ? String(value) : )); return ( = ({ // Link column: clicking navigates to the record detail cellRenderer = (value: any, row: any) => { const displayContent = pointerFace?.(row) ?? (LinkContentRenderer - ? + ? : (value != null && value !== '' ? String(value) : )); return ( = ({ // `dependentValues` for a cell whose value only means something // beside a sibling column (objectui#12613). cellRenderer = (value: any, row: any) => ( - pointerFace?.(row) ?? + pointerFace?.(row) ?? ); } else { // Default renderer with empty value handling @@ -4113,6 +4143,9 @@ export const ObjectGrid: React.FC = ({ // The percent storage statement, as path A copies it (objectui#11475). if (fieldDef.max !== undefined) fieldMeta.max = fieldDef.max; if (fieldDef.format) fieldMeta.format = fieldDef.format; + // The deadline declaration, as path A copies it (objectui#11815). + if (fieldDef.dueLike !== undefined) fieldMeta.dueLike = fieldDef.dueLike; + if (fieldDef.settledWhen !== undefined) fieldMeta.settledWhen = fieldDef.settledWhen; if (fieldDef.options) fieldMeta.options = translateOptions(schema.objectName, fieldName, fieldDef.options); } // Preserve relational metadata (reference, displayField, …) so @@ -4142,7 +4175,7 @@ export const ObjectGrid: React.FC = ({ if (isPrimaryField && LinkContentRenderer) { cellRenderer = (value: any, row: any) => { - const displayContent = pointerFace?.(row) ?? ; + const displayContent = pointerFace?.(row) ?? ; return ( = ({ ); } else if (CellRenderer) { - cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ; + cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ; } return { @@ -4297,6 +4330,9 @@ export const ObjectGrid: React.FC = ({ // The percent storage statement, as path A copies it (objectui#11475). if (fieldDef.max !== undefined) fieldMeta.max = fieldDef.max; if (fieldDef.format) fieldMeta.format = fieldDef.format; + // The deadline declaration, as path A copies it (objectui#11815). + if (fieldDef.dueLike !== undefined) fieldMeta.dueLike = fieldDef.dueLike; + if (fieldDef.settledWhen !== undefined) fieldMeta.settledWhen = fieldDef.settledWhen; if (fieldDef.options) fieldMeta.options = translateOptions(schema.objectName, fieldName, fieldDef.options); } // Preserve relational metadata (reference, displayField, …) so @@ -4328,7 +4364,7 @@ export const ObjectGrid: React.FC = ({ ...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }), ...(inferredAlign && { align: inferredAlign }), ...(CellRenderer && { - cell: (value: any, row: any) => pointerFace?.(row) ?? , + cell: (value: any, row: any) => pointerFace?.(row) ?? , }), sortable: fieldDef?.sortable !== false, }; @@ -4436,13 +4472,13 @@ export const ObjectGrid: React.FC = ({ // Aligned on the RENDERER key, like paths A-C: a `text` column with // `format: 'currency'` renders as currency, so it aligns as currency. ...(!!rendererType && numericTypes.includes(rendererType) && { align: 'right' as const }), - cell: (value: any, row: any) => pointerFace?.(row) ?? , + cell: (value: any, row: any) => pointerFace?.(row) ?? , sortable: field.sortable !== false, }); }); return generatedColumns; - }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms, uiLanguage, dataSource]); + }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms, uiLanguage, dataSource, predicateScope]); // Formats this grid can actually deliver (objectui#2942): the server stream // handles csv/xlsx/json, the client fallback only csv/json. Declared-but-dead diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..d6d5c91ae8 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx @@ -0,0 +1,162 @@ +/** + * 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#11815 — through the real `ObjectGrid`, every column builder + * forwards a declared deadline (`dueLike`, `settledWhen`) and hands each row's + * cell `deadlineSettled`: a done row's past deadline reads neutral, an open + * row's reads "Overdue Nd" in red (ruling D). The projection asks for the + * `settledWhen` operand when no column shows it. + * + * The deadline is named `deliver_by` on purpose: no retired name guess can + * paint it, so only the declaration can. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { + assertNoOtherNetworkEscape, + installRecordSecurityExplainDouble, +} from '@object-ui/test-support'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => installRecordSecurityExplainDouble(vi)); +afterEach(() => { + assertNoOtherNetworkEscape(expect); + cleanup(); + vi.unstubAllGlobals(); +}); + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const TASK_SCHEMA = { + name: 'task', + label: 'Task', + fields: { + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + deliver_by: { + type: 'date', + label: 'Deliver by', + dueLike: true, + settledWhen: "record.status == 'done'", + }, + started_on: { type: 'date', label: 'Started on' }, + }, +}; + +const ROWS = [ + { id: 't-open', title: 'Open task', status: 'open', deliver_by: daysAgo(5), started_on: daysAgo(4) }, + { id: 't-done', title: 'Done task', status: 'done', deliver_by: daysAgo(5), started_on: daysAgo(4) }, +]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + getObjectSchema: vi.fn(async () => TASK_SCHEMA), + } as any; +} + +function renderGrid(schemaOverrides: Record, props: Record = {}, ds = makeDataSource()) { + render( + + + , + ); + return ds; +} + +const isRed = (text: string) => screen.getByText(text).closest('span.tabular-nums')?.className.includes('text-red-600'); + +/** One overdue (open row) and one neutral (done row) deadline, and the undeclared control. */ +async function expectOpenOverdueDoneNeutral() { + await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument()); + expect(screen.getAllByText('Overdue 5d')).toHaveLength(1); + expect(isRed('Overdue 5d')).toBe(true); + expect(screen.getAllByText('5 days ago')).toHaveLength(1); + expect(isRed('5 days ago')).toBe(false); + // CONTROL: an undeclared past date reads neutral on both rows. + expect(screen.getAllByText('4 days ago')).toHaveLength(2); +} + +const NAMES = ['title', 'deliver_by', 'started_on']; + +describe('objectui#11815 — every ObjectGrid path judges a declared deadline per row', () => { + it('A: object `columns`', async () => { + renderGrid({ columns: NAMES.map((field) => ({ field })), data: { provider: 'value', items: ROWS } }); + await expectOpenOverdueDoneNeutral(); + }); + + it('A: an object column drawn as a link', async () => { + renderGrid({ + columns: [{ field: 'title' }, { field: 'deliver_by', link: true }, { field: 'started_on' }], + data: { provider: 'value', items: ROWS }, + }); + await expectOpenOverdueDoneNeutral(); + }); + + it('B: string `columns`', async () => { + renderGrid({ columns: NAMES, data: { provider: 'value', items: ROWS } }); + await expectOpenOverdueDoneNeutral(); + }); + + it('B: string `columns` with the deadline as the primary (link) column', async () => { + renderGrid({ columns: ['deliver_by', 'title', 'started_on'], data: { provider: 'value', items: ROWS } }); + await expectOpenOverdueDoneNeutral(); + }); + + it('C: inline-data projection (`fields`, no `columns`)', async () => { + renderGrid({ fields: NAMES, data: { provider: 'value', items: ROWS } }); + await expectOpenOverdueDoneNeutral(); + }); + + it('D: object-schema default columns (rows handed down)', async () => { + renderGrid({}, { data: ROWS }); + await expectOpenOverdueDoneNeutral(); + }); +}); + +describe('objectui#11815 — the projection carries what settledWhen reads', () => { + it("asks for `status` when only Title and the deadline are shown, and judges each row", async () => { + const ds = renderGrid({ columns: ['title', 'deliver_by'] }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + const selects = ds.find.mock.calls.map(([, params]: any[]) => params?.$select); + expect(selects.length).toBeGreaterThan(0); + for (const sel of selects) expect(sel).toEqual(expect.arrayContaining(['id', 'title', 'deliver_by', 'status'])); + await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument()); + expect(screen.getAllByText('5 days ago')).toHaveLength(1); + }); + + it('CONTROL — an undeclared shown date pulls nothing extra into the projection', async () => { + const ds = renderGrid({ columns: ['title', 'started_on'] }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + for (const [, params] of ds.find.mock.calls as any[]) expect(params?.$select).not.toContain('status'); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 53373dc28d..fa6085a5ac 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -20,6 +20,7 @@ import { useDataInvalidation, useFilterScope, useResolvedFilter, + usePredicateScope, } from '@object-ui/react'; import { NavigationOverlay, @@ -42,6 +43,7 @@ import { isEmptyValue, resolveNameField, resolveAffordance, + isDeadlineSettled, type SchemaLike, } from '@object-ui/core'; import { getBadgeColorClasses, getBadgeHexAppearance, getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; @@ -1056,6 +1058,9 @@ export const ObjectKanban: React.FC = ({ const ownerPersonObject = typeof ownerReference === 'string' && ownerReference ? ownerReference : 'sys_user'; // Enhance data with title mapping and ensure IDs + // The host predicate scope a card's declared deadline is judged in + // (objectui#11815). + const predicateScope = usePredicateScope(); const effectiveData = useMemo(() => { if (!Array.isArray(rawData)) return []; @@ -1328,6 +1333,7 @@ export const ObjectKanban: React.FC = ({ ); cardFieldCells.push({ @@ -1428,7 +1434,7 @@ export const ObjectKanban: React.FC = ({ ...(cardOwner ? { cardOwner } : {}), }; }); - }, [rawData, schema, objectDef, ownerField, ownerReadable, ownerPersonObject, perms]); + }, [rawData, schema, objectDef, ownerField, ownerReadable, ownerPersonObject, perms, predicateScope]); // Generate columns if missing but groupBy is present const effectiveColumns = useMemo(() => { diff --git a/packages/plugin-kanban/src/__tests__/ObjectKanban.deadlineSettled-11815.test.tsx b/packages/plugin-kanban/src/__tests__/ObjectKanban.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..8a996dcb55 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/ObjectKanban.deadlineSettled-11815.test.tsx @@ -0,0 +1,79 @@ +/** + * 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#11815 — a kanban card judges a declared deadline against its own + * record: an open card's past deadline reads "Overdue Nd" in red, a done + * card's reads neutral (ruling D). + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-kanban`. +import '../index'; +// Cards render inside `KanbanRenderer`'s `React.lazy` boundary: imported at +// module scope so the cold transform is billed to the import phase (objectui#3010). +import '../KanbanImpl'; + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const TASK_DEF = { + name: 'task', + fields: { + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + deliver_by: { type: 'date', label: 'Deliver by', dueLike: true, settledWhen: "record.status == 'done'" }, + }, +}; + +const ROWS = [ + { id: 't-open', title: 'Open task', status: 'open', deliver_by: daysAgo(5) }, + { id: 't-done', title: 'Done task', status: 'done', deliver_by: daysAgo(5) }, +]; + +const isRed = (text: string) => screen.getByText(text).closest('span.tabular-nums')?.className.includes('text-red-600'); + +describe('objectui#11815 — kanban card', () => { + it("an open card's declared deadline reads overdue, a done card's reads neutral", async () => { + const adapter = { + find: vi.fn().mockResolvedValue({ data: ROWS }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(TASK_DEF), + }; + render( + + + , + ); + await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument()); + expect(isRed('Overdue 5d')).toBe(true); + expect(screen.getAllByText('5 days ago')).toHaveLength(1); + expect(isRed('5 days ago')).toBe(false); + }); +}); diff --git a/packages/plugin-list/src/ListViewCompact.tsx b/packages/plugin-list/src/ListViewCompact.tsx index 4c7d8777bd..2c67a38a4e 100644 --- a/packages/plugin-list/src/ListViewCompact.tsx +++ b/packages/plugin-list/src/ListViewCompact.tsx @@ -29,7 +29,8 @@ import * as React from 'react'; import { cn } from '@object-ui/components'; -import { getRecordDisplayName, isEmptyValue, columnIdentity } from '@object-ui/core'; +import { getRecordDisplayName, isEmptyValue, columnIdentity, isDeadlineSettled } from '@object-ui/core'; +import { usePredicateScope } from '@object-ui/react'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; @@ -136,6 +137,8 @@ export function ListViewCompactRows({ rows, fields, objectName, objectDef, onRow const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel(); const { primary, secondary } = compactRowColumns(fields); const destination = React.useContext(CompactListDestinationContext); + // The host predicate scope a row's declared deadline is judged in (objectui#11815). + const predicateScope = usePredicateScope(); // The field as the shared cell renderers take it: the object's definition, // its label and option labels through the i18n dictionary. @@ -160,7 +163,8 @@ export function ListViewCompactRows({ rows, fields, objectName, objectDef, onRow if (isEmptyValue(value)) return null; const field = fieldFor(name); const Cell = getCellRenderer(resolveCellRendererType(field as never)); - return ; + const deadlineSettled = isDeadlineSettled(objectDef?.fields?.[name], record, { scope: predicateScope, fields: objectDef?.fields }); + return ; }; return ( diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index fe6a7a3a7b..d27fe70878 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -7,8 +7,8 @@ */ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; -import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; -import { ComponentRegistry, buildExpandFields, getRecordDisplayName, isEmptyValue, isExpandableFieldType } from '@object-ui/core'; +import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter, usePredicateScope } from '@object-ui/react'; +import { ComponentRegistry, buildExpandFields, getRecordDisplayName, isEmptyValue, isExpandableFieldType, isDeadlineSettled } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; import type { DataSource, GalleryConfig, ObjectGallerySchema, QueryParams } from '@object-ui/types'; @@ -363,6 +363,9 @@ export const ObjectGallery: React.FC = (props) => { // same context: type, options, currency, precision, scale, reference // target, etc. This is what keeps card output visually aligned with the // record detail page. + // The host predicate scope a card's declared deadline is judged in + // (objectui#11815). + const predicateScope = usePredicateScope(); const buildEnrichedField = useCallback((fieldName: string) => { const def = objectDef?.fields?.[fieldName]; const enriched: Record = { name: fieldName }; @@ -388,6 +391,10 @@ export const ObjectGallery: React.FC = (props) => { // `50%` in the grid (objectui#11475). Same presence test as `scale`. if (def.max !== undefined) enriched.max = def.max; if (def.format) enriched.format = def.format; + // The field's deadline declaration (objectui#11815): the date cells + // paint overdue from `dueLike` alone, so without it a card's declared + // deadline never reads overdue. + if (def.dueLike !== undefined) enriched.dueLike = def.dueLike; // objectui#6837 half 2 — maintainer 2026-08-31: protocol normalization // belongs on the SERVER, the front end just executes the protocol. // `reference` is the only target spelling `@objectstack/spec`'s @@ -725,7 +732,11 @@ export const ObjectGallery: React.FC = (props) => { {fieldLabel} )} - + ); })} diff --git a/packages/plugin-list/src/__tests__/deadlineSettled-11815.test.tsx b/packages/plugin-list/src/__tests__/deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..311b72e572 --- /dev/null +++ b/packages/plugin-list/src/__tests__/deadlineSettled-11815.test.tsx @@ -0,0 +1,77 @@ +/** + * 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#11815 — the gallery card and the compact list judge a declared + * deadline against the row they draw: an open row's past deadline reads + * "Overdue Nd" in red, a done row's reads neutral (ruling D). + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ObjectGallery } from '../ObjectGallery'; +import { ListViewCompactRows } from '../ListViewCompact'; + +afterEach(cleanup); + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const objectSchema = { + name: 'task', + fields: { + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + deliver_by: { type: 'date', label: 'Deliver by', dueLike: true, settledWhen: "record.status == 'done'" }, + }, +}; + +const rows = [ + { id: 't-open', title: 'Open task', status: 'open', deliver_by: daysAgo(5) }, + { id: 't-done', title: 'Done task', status: 'done', deliver_by: daysAgo(5) }, +]; + +const isRed = (text: string) => screen.getByText(text).closest('span.tabular-nums')?.className.includes('text-red-600'); + +describe('objectui#11815 — gallery card', () => { + it("an open card's declared deadline reads overdue, a done card's reads neutral", async () => { + const ds = { + find: vi.fn().mockResolvedValue(rows), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(objectSchema), + }; + render( + + + , + ); + await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument()); + expect(isRed('Overdue 5d')).toBe(true); + expect(screen.getAllByText('5 days ago')).toHaveLength(1); + expect(isRed('5 days ago')).toBe(false); + }); +}); + +describe('objectui#11815 — compact list', () => { + it("an open row's declared deadline reads overdue, a done row's reads neutral", () => { + render(); + expect(screen.getByText('Overdue 5d')).toBeInTheDocument(); + expect(isRed('Overdue 5d')).toBe(true); + expect(screen.getAllByText('5 days ago')).toHaveLength(1); + expect(isRed('5 days ago')).toBe(false); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.deadlineSettled-11815.test.tsx b/packages/plugin-tree/src/ObjectTree.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..d4a409bcce --- /dev/null +++ b/packages/plugin-tree/src/ObjectTree.deadlineSettled-11815.test.tsx @@ -0,0 +1,67 @@ +/** + * 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#11815 — the tree judges a declared deadline against each node's + * record: an open node's past deadline reads "Overdue Nd" in red, a done + * node's reads neutral (ruling D). + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { DataSource } from '@object-ui/types'; +import { ObjectTree } from './ObjectTree'; + +afterEach(cleanup); + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const OBJECT_SCHEMA = { + name: 'milestone', + fields: { + name: { type: 'text', label: 'Name' }, + parent_id: { type: 'tree', reference: 'milestone', label: 'Parent' }, + status: { type: 'text', label: 'Status' }, + deliver_by: { type: 'date', label: 'Deliver by', dueLike: true, settledWhen: "record.status == 'done'" }, + }, +}; + +const ROWS = [ + { id: 'm1', name: 'Open milestone', parent_id: null, status: 'open', deliver_by: daysAgo(5) }, + { id: 'm2', name: 'Done milestone', parent_id: null, status: 'done', deliver_by: daysAgo(5) }, +]; + +const TREE_SCHEMA = { + type: 'object-tree', + objectName: 'milestone', + parentField: 'parent_id', + labelField: 'name', + fields: ['name', 'status', 'deliver_by'], +}; + +const ds = { + find: async () => ROWS, + getObjectSchema: async () => OBJECT_SCHEMA, +} as unknown as DataSource; + +const isRed = (text: string) => screen.getByText(text).closest('span.tabular-nums')?.className.includes('text-red-600'); + +describe('objectui#11815 — tree', () => { + it("an open node's declared deadline reads overdue, a done node's reads neutral", async () => { + render(); + await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument()); + expect(isRed('Overdue 5d')).toBe(true); + expect(screen.getAllByText('5 days ago')).toHaveLength(1); + expect(isRed('5 days ago')).toBe(false); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 56774d870a..824607477a 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -50,6 +50,7 @@ import { useFilterScope, useResolvedFilter, useDataInvalidation, + usePredicateScope, } from '@object-ui/react'; import { NavigationOverlay, @@ -81,6 +82,7 @@ import { ValueDataSource, applyNonGridRowCeiling, nonGridRowCeilingQuery, + isDeadlineSettled, type NonGridCeilingResult, } from '@object-ui/core'; import { ChevronRight, ChevronDown } from 'lucide-react'; @@ -545,6 +547,8 @@ interface CellFormatContext { objectName?: string; /** `useSafeFieldLabel().translateOptions` — identity without a provider. */ translateOptions: TranslateOptions; + /** The host's `isDeadlineSettled` verdict for this cell's row (objectui#11815). */ + deadlineSettled?: boolean; } /** @@ -602,7 +606,7 @@ function renderTreeCell(value: unknown, ctx: CellFormatContext, empty = ''): Rea label: ctx.columnLabel, ...(options ? { options } : {}), }; - return ; + return ; } /** @@ -1098,6 +1102,8 @@ export const ObjectTree: React.FC = ({ // Column labels: i18n convention key (`objects.{obj}.fields.{field}.label`) // first, then the object schema's authored label, then a humanized field key. const i18n = useSafeFieldLabel(); + // The host predicate scope a row's declared deadline is judged in (objectui#11815). + const predicateScope = usePredicateScope(); // The `?? schema.objectName` tail is NOT the shared rung repeated: it is this // site's own coercion of the OFF-CONTRACT `data: { provider: 'object' }` that // carries no `object` (`ViewDataSchema` declares it required), kept so the @@ -1116,12 +1122,13 @@ export const ObjectTree: React.FC = ({ * from the SAME `objectSchema.fields` map the header labels read, so a column * cannot be labelled from the schema and then formatted without it. */ - const cellContext = (field: string): CellFormatContext => ({ + const cellContext = (field: string, row?: Record): CellFormatContext => ({ fieldDef: objectSchema?.fields?.[field], fieldName: field, columnLabel: fieldLabel(field), objectName: headerObjectName, translateOptions: i18n.translateOptions, + deadlineSettled: isDeadlineSettled(objectSchema?.fields?.[field], row, { scope: predicateScope, fields: objectSchema?.fields }), }); const navigation = useNavigationOverlay({ @@ -1353,7 +1360,7 @@ export const ObjectTree: React.FC = ({ )} - {renderTreeCell(node.record[config.labelField], cellContext(config.labelField), '—')} + {renderTreeCell(node.record[config.labelField], cellContext(config.labelField, node.record), '—')} @@ -1361,7 +1368,7 @@ export const ObjectTree: React.FC = ({ .filter((f) => f !== config.labelField) .map((f) => ( - {renderTreeCell(node.record[f], cellContext(f))} + {renderTreeCell(node.record[f], cellContext(f, node.record))} ))} diff --git a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts index 68d38b9d2a..cf1e10f3ce 100644 --- a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts +++ b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts @@ -43,6 +43,10 @@ * mirror is ⛔ untouched — it already stated the key — so the section below * that measures the mirror is unchanged, and it is the TypeScript half at * the bottom of this file that inverted. + * ⚠️ Dated note (objectui#11815, ruling D): the key has since RETIRED + * from both faces — the deadline is the object field's declaration, read + * through enrichment — so both `dueLike` sections below now pin its + * absence. * * What stays, in both cases, is the BEFORE/AFTER continuity, so neither defect * this card measured can quietly return under the same name. @@ -99,25 +103,27 @@ import type { ObjectGridSchema as ObjectGridSchemaType } from '../objectql.js'; /** A control key no surface in this package declares. */ const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares9729'; -describe('views.zod.ts#DetailViewFieldSchema — `dueLike`', () => { - it('the mirror JUDGES the key, while an unknown key is stripped unexamined', () => { - const judged = DetailViewFieldSchema.safeParse({ name: 'end_date', dueLike: true }); - expect(judged.success).toBe(true); - // KEPT, not stripped — the mirror states it. - expect(judged.success && judged.data).toMatchObject({ dueLike: true }); - - // LIT CONTROL, same schema, same document shape: an unstated key survives - // the parse and is GONE from the output. So "green" is not the evidence — - // the key surviving into `data` is. - const control = DetailViewFieldSchema.safeParse({ name: 'end_date', [UNKNOWN_KEY]: true }); - expect(control.success).toBe(true); - expect(control.success && Object.keys(control.data)).not.toContain(UNKNOWN_KEY); - }); +describe('views.zod.ts#DetailViewFieldSchema — `dueLike`, retired by objectui#11815', () => { + /** + * ⭐ INVERTED BY RULING, ⛔ not relaxed. objectui#9738 letter A declared the + * key on both faces; objectui#11815 (ruling D) retired it from the authored + * view entry altogether: the deadline is the object FIELD's declaration + * (`DateFieldMetadata.dueLike` / `settledWhen`, by reference to the spec), + * read through `enrichDetailField`. So the mirror no longer judges the key — + * it strips it exactly as it strips an unknown one, which the lit control + * beside it shows is the same instrument answering. + */ + it('the mirror no longer states the key, and strips it like an unknown one', () => { + expect(Object.keys(DetailViewFieldSchema.shape)).not.toContain('dueLike'); + const retired = DetailViewFieldSchema.safeParse({ name: 'end_date', dueLike: true }); + expect(retired.success).toBe(true); + expect(retired.success && Object.keys(retired.data)).not.toContain('dueLike'); - it('the mirror refuses a wrong-typed `dueLike` BY NAME', () => { - const r = DetailViewFieldSchema.safeParse({ name: 'end_date', dueLike: 'yes' }); - expect(r.success).toBe(false); - expect(!r.success && r.error.issues.map((i) => i.path.join('.'))).toContain('dueLike'); + // LIT CONTROL, same schema: a key the mirror DOES state survives the parse. + const control = DetailViewFieldSchema.safeParse({ name: 'end_date', currency: 'USD' }); + expect(control.success && control.data).toMatchObject({ currency: 'USD' }); + const unknown = DetailViewFieldSchema.safeParse({ name: 'end_date', [UNKNOWN_KEY]: true }); + expect(unknown.success && Object.keys(unknown.data)).not.toContain(UNKNOWN_KEY); }); }); @@ -199,25 +205,12 @@ describe('complex.zod.ts#DashboardConfigSchema — `aria` is the CONTRAST, not t /* ── The TypeScript side, where the two keys stop behaving alike ───────────── */ /** - * `DetailViewField` ACCEPTS `dueLike`. This binding IS the assertion, and it is - * read by the package's `tsc -p tsconfig.test.json` leg. - * - * ⭐ THE DIRECTION OF THIS ASSERTION IS INVERTED FROM WHAT THIS CARD MEASURED, - * deliberately and by ruling — ⛔ not relaxed, and ⛔ not deleted, to keep a leg - * green. What stood here was a `@ts-expect-error` recording the REFUSAL, written - * so that it would fail in BOTH directions; objectui#9738 letter A declared the - * key on the twin, which is precisely the direction that turns such a directive - * unused (`TS2578`, measured on this file). So the measurement moves WITH the - * declaration, onto the same literal, and stays discriminating in its new - * direction: the twin losing the member again makes this line `TS2353` on the - * same leg. - * - * ⚠️ The assignment alone would not be enough, and the second assertion below is - * why. An index signature added to `DetailViewField` would let this literal - * compile while `dueLike` is undeclared — exactly the shape `ObjectGridSchema` - * has further up this file, where an authored key is absorbed as `any` and - * nothing contradicts it. The exact `Equal` separates DECLARED from ABSORBED. + * `DetailViewField` REFUSES `dueLike` since objectui#11815 (ruling D) retired + * the authored view-entry key. This directive IS the assertion, read by the + * package's `tsc -p tsconfig.test.json` leg: re-declaring the member makes it + * unused (`TS2578`) on the same leg. */ +// @ts-expect-error — objectui#11815: the deadline is the FIELD's declaration, not the view entry's export const authoredDueLike: DetailViewField = { name: 'end_date', dueLike: true }; /** LIT CONTROL: a key the twin DOES declare is accepted on the same literal. */ @@ -233,20 +226,9 @@ type Equal< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; type Expect< T extends true > = T; -/** - * The member is DECLARED with a real type, not absorbed by an index signature - * and not widened to `any` — `boolean | undefined`, exactly. - * - * ⛔ `extends` is not an acceptable weakening here: `any` and a `boolean`-typed - * index signature both satisfy an `extends` written in either direction, and - * those are the two ways this declaration could be present in a listing while - * being absent as a contract. The mirror's own judgement of the same key — that - * `dueLike: 'yes'` is refused BY NAME — is pinned at the top of this file, so - * the two published faces are asserted to agree on the TYPE as well as on the - * name. - */ -export type assertionDueLikeIsDeclaredOnTheTwin = - Expect< Equal< DetailViewField['dueLike'], boolean | undefined > >; +/** The member is gone from the twin, not merely made optional (objectui#11815). */ +export type assertionDueLikeIsRetiredFromTheTwin = + Expect< Equal< 'dueLike' extends keyof DetailViewField ? true : false, false > >; /** * The TWIN refuses `operators` too since objectui#8347 removed `BaseSchema`'s diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 76e7a59efc..5e28ca14a6 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -214,60 +214,13 @@ export interface DetailViewField { * Currency code for currency fields (e.g. 'USD', 'EUR') */ currency?: string; - /** - * Marks a `date` / `datetime` field as due/deadline-semantic (vs. a plain - * start/end/created date). It is the same key, with the same meaning, as - * `DateFieldMetadata.dueLike` and `DateTimeFieldMetadata.dueLike` in - * `./field-types.ts` — those two are OBJECT metadata; this one is the - * AUTHORED detail-view field, which is a different key of the same name. - * - * ## What it does to the rendered cell - * - * Both halves of the overdue affordance, on the `date` and the `datetime` - * cell alike: - * - * - **wording** — inside the relative face, a past due date reads - * `Overdue Nd` instead of the neutral `N days ago`. `formatRelativeDate` - * (`@object-ui/core`) gates that phrase on this key; the phrase has no - * `Intl` equivalent, so it is also the only route by which that function - * reaches the translate fn. - * - **styling** — the cell's span gains `text-red-600` once the deadline - * day has passed, whichever display face it is painting - * (`isOverdueInstant` in `@object-ui/fields`). - * - * ⚠️ The two halves have different thresholds, inherited from the shared - * relative-time path and ⛔ not re-decided here: the red styling starts the - * day after the deadline, while the `Overdue Nd` wording starts the day after - * that (`formatRelativeDate` gates its phrase on a difference of more than - * one calendar day, so `Overdue 2d` is the shortest phrase this codebase - * produces). Beyond a week the relative face falls back to an absolute date - * and the wording stops; the styling does not. - * - * ## Why it is declared HERE, on the detail-view field - * - * Because the renderer already honours it here, on the AUTHORED field of a - * detail view — not only on object metadata. `DetailSection` - * (`@object-ui/plugin-detail`) spreads the authored field into the bag - * `enrichDetailField` returns and hands that bag to the resolved cell - * renderer, whose `resolveDueLike` reads this key first and falls back to the - * due/deadline field-NAME convention only when it is not `true`. That read is - * measured by rendering rather than by grep, in that package's - * `DetailSection.dueLikeReachesTheCell-9729.test.tsx`, which draws the same - * `end_date` field with and without the key and watches the drawn wording - * change. - * - * Until objectui#9738 this interface was the one published face that refused - * the key (`TS2353` — it carries no index signature) while - * `DetailViewFieldSchema` in `./zod/views.zod.ts` validated it and the - * renderer honoured it. The declaration is that contradiction's remedy - * (maintainer ruling, letter A), ⛔ not a new capability: the obligation was - * already on the books, with a reader and two documentation pages. - * - * ⚠️ Omitting the key is NOT the same as writing `false`. Absent, the - * field-NAME convention can still turn the affordance on; `false` does not - * suppress that fallback either — only a neutral field name does. - */ - dueLike?: boolean; + // ⛔ No `dueLike` here (objectui#11815, ruling D). The deadline semantic is + // the FIELD's declaration — `DateFieldMetadata.dueLike` / + // `DateTimeFieldMetadata.dueLike` and their partner `settledWhen`, by + // reference to `@objectstack/spec` — and the detail view reads it from there + // through `enrichDetailField`. The authored view-entry key that used to sit + // here retired with no alias: the spec's view schemas are strict, so an + // application validated by `@objectstack/spec` could never author it. } /** diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index e531cf9196..6ea22bd793 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -90,9 +90,8 @@ export const DetailViewFieldSchema = z.object({ reference: z.string().optional().describe('Referenced object name for lookup/master_detail fields'), reference_field: z.string().optional().describe('Display field on the referenced object'), currency: z.string().optional().describe('Currency code for currency fields (e.g. USD, EUR)'), - dueLike: z.boolean().optional().describe( - 'Marks a date/datetime field as due/deadline-semantic, gating the relative "Overdue Nd" wording (vs. neutral "Nd ago" for start/end/created dates)', - ), + // `dueLike` retired from the authored view entry (objectui#11815): the + // deadline is the object field's declaration, read through enrichment. }); /**