diff --git a/.changeset/11815-app-shell-approval-settled.md b/.changeset/11815-app-shell-approval-settled.md new file mode 100644 index 0000000000..1de43abec2 --- /dev/null +++ b/.changeset/11815-app-shell-approval-settled.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The approval decision panel's record summary honours a declared deadline's `settledWhen` (objectui#11815), judged against the snapshot it shows: a done record's past due date reads neutral, an open one's reads overdue. diff --git a/.changeset/11815-console-approval-settled.md b/.changeset/11815-console-approval-settled.md new file mode 100644 index 0000000000..0ea7a71150 --- /dev/null +++ b/.changeset/11815-console-approval-settled.md @@ -0,0 +1,5 @@ +--- +'@object-ui/console': patch +--- + +The approvals inbox's record summary honours a declared deadline's `settledWhen` (objectui#11815), judged against the submitted snapshot it shows: a done record's past due date reads neutral, an open one's reads overdue. diff --git a/.changeset/11815-list-settled-select.md b/.changeset/11815-list-settled-select.md new file mode 100644 index 0000000000..8679040fa0 --- /dev/null +++ b/.changeset/11815-list-settled-select.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-list': patch +--- + +A list view that shows a declared deadline but not the field its `settledWhen` reads now fetches that field, so a done record's past due date reads neutral instead of overdue (objectui#11815). A field the viewer may not read is still never requested; that deadline stays unsettled. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.deadlineSettled-11815.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.deadlineSettled-11815.test.tsx new file mode 100644 index 0000000000..821aaaf43e --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.deadlineSettled-11815.test.tsx @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approvals drawer — a declared deadline in the record summary card is judged + * against the snapshot the card draws (objectui#11815, ruling D). + * + * `RecordFieldFace` draws a snapshot value through the record page's own + * cell. The date cell paints a declared deadline (`dueLike: true`) from the + * declaration alone and cannot evaluate `settledWhen`, so the face hands it + * `deadlineSettled`, judged by `isDeadlineSettled` against the request's + * `payload` — the same snapshot the face reads its value from. The snapshot's + * date and its status were frozen together at submission, so a done + * snapshot's past deadline reads neutral and an open one's reads overdue. + * + * Harness: `ApprovalsInboxPage.summaryFaces-11677.test.tsx`'s, reduced to one + * object (`setup.access` keeps the raw-JSON panel away for the same reason). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, within, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +const APP = 'com.example.showcase'; + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA } = vi.hoisted(() => { + const SCHEMA = { + name: 'showcase_task', + label: 'Task', + fields: { + name: { type: 'text', label: 'Title' }, + status: { + type: 'select', label: 'Status', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Done', value: 'done' }, + ], + }, + due_date: { type: 'date', label: 'Due', dueLike: true, settledWhen: "record.status == 'done'" }, + // CONTROL: a past date with no declaration is never a deadline. + reviewed_on: { type: 'date', label: 'Reviewed On' }, + }, + }; + const getObjectSchema = vi.fn(async (name: string): Promise => (name === SCHEMA.name ? SCHEMA : { fields: {} })); + const approvalsApiStub = { + listRequests: vi.fn(async (): Promise<{ data: unknown[]; total: number }> => ({ data: [], total: 0 })), + getRequest: vi.fn(async (): Promise<{ data: unknown }> => ({ data: null })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(), + reject: vi.fn(), + }; + // STABLE singletons, as in the summaryFaces harness. + const ADAPTER = { find: vi.fn(async () => ({ data: [] })), getObjectSchema }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + const I18N = { + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }; + return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +// Imported after the mocks so the page picks them up. +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +function permissionsPayload(): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: 't_1', + roles: [], + permissionSets: [], + objects: {}, + fields: {}, + systemPermissions: ['setup.access'], + }; +} + +function requestFor(status: string): Record { + const payload = { name: `Task ${status}`, status, due_date: daysAgo(5), reviewed_on: daysAgo(5) }; + return { + id: `req_${status}`, + status: 'pending', + pending_approvers: ['u_1'], + submitter_id: 'u_9', + submitter_name: 'Dev Admin', + submitted_at: '2026-10-06T05:00:00.000Z', + process_name: 'task_signoff', + process_label: `Task Sign-off (${status})`, + object_name: SCHEMA.name, + object_label: 'Task', + record_id: `t_${status}`, + record_title: `Task ${status}`, + payload, + payload_labels: {}, + payload_display: {}, + }; +} + +beforeEach(() => { + getObjectSchema.mockClear(); + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +async function openedDrawer(row: Record): Promise { + approvalsApiStub.listRequests.mockResolvedValue({ data: [row], total: 1 }); + approvalsApiStub.getRequest.mockResolvedValue({ data: row }); + render( + + + + } /> + + + , + ); + const dialog = await screen.findByRole('dialog'); + await within(dialog).findByText(String(row.process_label)); + return dialog; +} + +/** The summary row's value cell for one field key. */ +function valueCell(dialog: HTMLElement, key: string): HTMLElement | null { + const row = dialog.querySelector(`[data-summary-field="${key}"]`); + return (row?.children[1] as HTMLElement | undefined) ?? null; +} + +const isRed = (el: HTMLElement | null) => !!el?.querySelector('.text-red-600'); + +describe('Approvals drawer summary — a declared deadline is judged against the snapshot (objectui#11815)', () => { + it("a snapshot whose `settledWhen` holds draws its past deadline neutral", async () => { + const dialog = await openedDrawer(requestFor('done')); + await waitFor(() => expect(valueCell(dialog, 'due_date')?.textContent).toBe('5 days ago')); + expect(isRed(valueCell(dialog, 'due_date'))).toBe(false); + }); + + it("a snapshot whose `settledWhen` does not hold draws its past deadline overdue", async () => { + const dialog = await openedDrawer(requestFor('open')); + await waitFor(() => expect(valueCell(dialog, 'due_date')?.textContent).toBe('Overdue 5d')); + expect(isRed(valueCell(dialog, 'due_date'))).toBe(true); + }); + + it('CONTROL: an undeclared past date on the open snapshot is never overdue', async () => { + const dialog = await openedDrawer(requestFor('open')); + await waitFor(() => expect(valueCell(dialog, 'reviewed_on')).not.toBeNull()); + expect(valueCell(dialog, 'reviewed_on')?.textContent).not.toMatch(/Overdue/); + expect(isRed(valueCell(dialog, 'reviewed_on'))).toBe(false); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 8333f03bfc..9b083a976d 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -79,9 +79,9 @@ import { toast } from 'sonner'; import { useAuth } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; -import { useMetadata } from '@object-ui/react'; +import { useMetadata, usePredicateScope } from '@object-ui/react'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; -import { isExpandableFieldType } from '@object-ui/core'; +import { isDeadlineSettled, isExpandableFieldType } from '@object-ui/core'; import { isSystemManagedField, type FieldMetadata } from '@object-ui/types'; import { APPROVAL_STATUS_LABELS } from '@objectstack/spec/contracts'; import { @@ -513,18 +513,31 @@ function useRequestFieldDefs(objectName: string | null | undefined): FieldDefs | * to the record page's own cell and adds nothing. A face that looks wrong here * looks wrong on the record page as well, and the fix belongs in the cell. * + * A declared deadline is judged against `snapshot`, the request's `payload` + * the face draws `value` from (objectui#11815): the snapshot's date and its + * status were frozen together at submission, so its `settledWhen` is read off + * the snapshot's own state, never the live record's. + * * Module scope on purpose, for the same reason as the row cells below * (objectui#5348). */ -function RecordFieldFace({ objectName, def, value }: { objectName: string; def: FieldDef; value: unknown }) { +function RecordFieldFace({ objectName, def, value, snapshot, fields }: { + objectName: string; + def: FieldDef; + value: unknown; + snapshot: Record; + fields: FieldDefs; +}) { const { translateOptions } = useSafeFieldLabel(); + const predicateScope = usePredicateScope(); const options = def.options; const field = Array.isArray(options) && options.length > 0 ? { ...def, options: translateOptions(objectName, def.name as string, options) } : def; const Cell = getCellRenderer(resolveCellRendererType(field as { type?: string; format?: string })); + const deadlineSettled = isDeadlineSettled(def, snapshot, { scope: predicateScope, fields }); // eslint-disable-next-line react-hooks/static-components -- getCellRenderer returns the registry's module-level renderer for the type, not a component created during render - return ; + return ; } /** One row of the drawer's summary card. `key` is the snapshot key, so it is unique. */ @@ -643,7 +656,7 @@ function payloadSummary( out.push({ key: k, label: declaredLabel(meta, k, def), - value: resolved ?? , + value: resolved ?? } fields={meta.defs} />, title: resolved, }); } else { @@ -2462,6 +2475,8 @@ export function ApprovalsInboxPage() { objectName={summaryMeta.objectName} def={leadDef} value={(selected.payload as Record)[drawerAmount.key]} + snapshot={selected.payload as Record} + fields={summaryMeta.defs} /> ), title: selected.payload_display?.[drawerAmount.key], diff --git a/content/docs/fields/date.mdx b/content/docs/fields/date.mdx index c9c949bbef..221cbb2ed0 100644 --- a/content/docs/fields/date.mdx +++ b/content/docs/fields/date.mdx @@ -62,10 +62,11 @@ lists, kanban and gallery cards, the compact list and the tree — evaluates 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. +⚠️ The predicate reads the record as the view fetched it. A grid, a list view and a +related list add the fields `settledWhen` reads to their own query, so the field the +predicate reads need not be a column. A field the viewer may not read is never +fetched, so a deadline whose predicate reads one stays unsettled for that viewer. An +approval's record summary judges the deadline against the snapshot it shows. 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/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.test.tsx b/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.test.tsx index ba29ba8224..6d7a8ce840 100644 --- a/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.test.tsx +++ b/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.test.tsx @@ -233,3 +233,43 @@ describe('RecordSnapshotSummary: the summarized object\'s definition (objectui#1 expect(leadOf(summary)).toBe('total'); }); }); + +/** + * objectui#11815 (ruling D): a declared deadline is judged against the + * snapshot the face draws, so a done snapshot's past deadline reads neutral + * and an open one's reads overdue. The snapshot's date and status were frozen + * together; its own state is the consistent reading. + */ +describe('RecordSnapshotSummary — a declared deadline is judged against the snapshot (objectui#11815)', () => { + const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); + }; + const TASK = { + name: 'task', + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'text', label: 'Status' }, + due_date: { type: 'date', label: 'Due', dueLike: true, settledWhen: "record.status == 'done'" }, + }, + }; + const dueCell = async () => { + const summary = await screen.findByTestId('record-snapshot-summary'); + return summary.querySelector('[data-summary-field="due_date"]')?.children[1] as HTMLElement | undefined; + }; + + it("a snapshot whose `settledWhen` holds draws its past deadline neutral", async () => { + mount({ task: TASK }, { objectName: 'task', snapshot: { name: 'T', status: 'done', due_date: daysAgo(5) } }); + const cell = await dueCell(); + expect(cell?.textContent).toBe('5 days ago'); + expect(cell?.querySelector('.text-red-600')).toBeNull(); + }); + + it("a snapshot whose `settledWhen` does not hold draws its past deadline overdue", async () => { + mount({ task: TASK }, { objectName: 'task', snapshot: { name: 'T', status: 'open', due_date: daysAgo(5) } }); + const cell = await dueCell(); + expect(cell?.textContent).toBe('Overdue 5d'); + expect(cell?.querySelector('.text-red-600')).not.toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.tsx b/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.tsx index 0a85308853..5167b9cae3 100644 --- a/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.tsx +++ b/packages/app-shell/src/views/approval-decision/RecordSnapshotSummary.tsx @@ -68,24 +68,39 @@ */ import * as React from 'react'; -import { withoutDeniedFields } from '@object-ui/core'; +import { isDeadlineSettled, withoutDeniedFields } from '@object-ui/core'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { useSafeFieldLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; -import { useMetadataItem } from '@object-ui/react'; +import { useMetadataItem, usePredicateScope } from '@object-ui/react'; import type { FieldMetadata } from '@object-ui/types'; import { distinctLabels, planSnapshotSummary, type FieldDef } from './snapshotSummaryPlan.js'; -/** A declared field's stored value, through the record page's own cell. */ -function FieldFace({ objectName, def, value }: { objectName: string; def: FieldDef; value: unknown }) { +/** + * A declared field's stored value, through the record page's own cell. + * + * A declared deadline is judged against `snapshot`, the same record values the + * face draws `value` from (objectui#11815): the snapshot's date and its status + * were frozen together, so its `settledWhen` is read off the snapshot's own + * state, never the live record's. + */ +function FieldFace({ objectName, def, value, snapshot, fields }: { + objectName: string; + def: FieldDef; + value: unknown; + snapshot: Record; + fields: Record; +}) { const { translateOptions } = useSafeFieldLabel(); + const predicateScope = usePredicateScope(); const options = def.options; const field = Array.isArray(options) && options.length > 0 ? { ...def, options: translateOptions(objectName, def.name as string, options) } : def; const Cell = getCellRenderer(resolveCellRendererType(field as { type?: string; format?: string })); + const deadlineSettled = isDeadlineSettled(def, snapshot, { scope: predicateScope, fields }); // eslint-disable-next-line react-hooks/static-components -- getCellRenderer returns the registry's module-level renderer for the type, not a component created during render - return ; + return ; } export interface RecordSnapshotSummaryProps { @@ -153,7 +168,7 @@ function DeclaredSummary({ target, fields, shown, display }: { const labelOf = new Map(keys.map((key, i) => [key, labels[i]])); const valueOf = (key: string) => display?.[key] ?? ( - + ); return ( diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index f5c036c52d..81c939b33b 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -3074,6 +3074,24 @@ export const ListView = React.forwardRef(({ // declare is never sent. addSpeculative(rowColorConfig?.field); + // [objectui#11815] A shown DECLARED deadline's `settledWhen` + // operands. The cells judge `isDeadlineSettled` against the row + // they draw, and an absent key is a CEL fault (`No such key`), not + // a null — so a list showing Title and Due but not `status` fetched + // rows without it, and a done row's deadline still read overdue. + // Asked of the columns that survived the FLS gate (`cols`): a + // deadline the viewer may not read is not drawn, so nothing judges + // it. Through `addSpeculative`, like every binding here: an operand + // the viewer may not read is never requested, and that row's + // deadline then stays unsettled (it fails toward overdue). + const deadlineDefs = objectDef?.fields as Record | undefined; + for (const f of collectPredicateFieldRefs( + cols + .map((c) => deadlineDefs?.[c]) + .filter((def) => def?.dueLike === true && def.settledWhen != null) + .map((def) => def!.settledWhen), + )) addSpeculative(f); + for (const f of collectPredicateFieldRefs(listViewPredicates({ conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined, /** diff --git a/packages/plugin-list/src/__tests__/ListView.deadlineSettledSelect-11815.test.tsx b/packages/plugin-list/src/__tests__/ListView.deadlineSettledSelect-11815.test.tsx new file mode 100644 index 0000000000..13aa9a106a --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.deadlineSettledSelect-11815.test.tsx @@ -0,0 +1,182 @@ +/** + * 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 list showing a declared deadline projects its + * `settledWhen` operands. + * + * The grid judges each row's declared deadline with `isDeadlineSettled` + * against the row it draws. `ListView` builds the `$select` the rows arrive + * through, and a list showing Title and Due but not `status` fetched rows + * without it: CEL faults `No such key`, the predicate fails, and a done row's + * deadline still read overdue. `ObjectGrid`'s own projection gained this arm + * in the same card; this is `ListView`'s harvest, beside its `rowColor` arm + * (`ListView.rowColorSelect-12376.test.tsx`). + * + * What is pinned: + * - a shown declared deadline's operand is in `$select`, and with a backend + * that honours `$select` a done row reads neutral, an open one overdue. + * `plugin-grid` is not a dependency of `plugin-list` (that would be a + * cycle), so the grid slot is stubbed, as `ListView.viewSwitchRefetch-7394` + * does: the stub draws each row ListView hands it through the real date + * cell and the real `isDeadlineSettled`, the call `ObjectGrid` makes (its + * own pins live in `plugin-grid`); + * - CONTROL: a date that does not declare `dueLike` pulls nothing in, even + * with a `settledWhen` beside it; + * - CONTROL: an operand the viewer may not read is not requested (the FLS + * gate inside `addSpeculative`, objectui#7216). + */ +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; + +/** Same stub shape as `ListView.rowColorSelect-12376.test.tsx`. */ +const { holder, makePerms } = vi.hoisted(() => { + const makePerms = (isLoaded: boolean, denied: string[]) => ({ + isLoaded, + checkField: (_object: string, field: string, action: string) => + action === 'read' ? !denied.includes(field) : true, + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }); + return { holder: { current: makePerms(true, []) }, makePerms }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => holder.current as any }; +}); + +import { ListView } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ComponentRegistry, isDeadlineSettled } from '@object-ui/core'; +import { getCellRenderer } from '@object-ui/fields'; + +const OBJECT = 'task'; + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().slice(0, 10); +}; + +const objectDef = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text' }, + title: { name: 'title', type: 'text', label: 'Title' }, + status: { name: 'status', type: 'text', label: 'Status' }, + secret_flag: { name: 'secret_flag', type: 'boolean', label: 'Secret flag' }, + deliver_by: { name: 'deliver_by', type: 'date', label: 'Deliver by', dueLike: true, settledWhen: "record.status == 'done'" }, + // Not a declared deadline: its `settledWhen` is read by nothing. + reviewed_on: { name: 'reviewed_on', type: 'date', label: 'Reviewed on', settledWhen: "record.status == 'done'" }, + // A declared deadline whose operand the viewer may not read. + gated_by: { name: 'gated_by', type: 'date', label: 'Gated by', dueLike: true, settledWhen: 'record.secret_flag == true' }, + }, +}; + +const DENIED = ['secret_flag']; + +const ROWS = [ + { id: 't-open', title: 'Open task', status: 'open', secret_flag: true, deliver_by: daysAgo(5), reviewed_on: daysAgo(5), gated_by: daysAgo(5) }, + { id: 't-done', title: 'Done task', status: 'done', secret_flag: true, deliver_by: daysAgo(5), reviewed_on: daysAgo(5), gated_by: daysAgo(5) }, +]; + +/** A backend that honours `$select`: a key not asked for does not arrive. */ +const makeDataSource = () => + ({ + find: vi.fn(async (_object: string, params: { $select?: string[] } = {}) => { + const select = params.$select; + const data = select + ? ROWS.map((r) => Object.fromEntries(Object.entries(r).filter(([k]) => select.includes(k)))) + : ROWS; + return { data, total: data.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => (name === OBJECT ? objectDef : null)), + }) as any; + +function mount(columns: string[]) { + const dataSource = makeDataSource(); + const schema: any = { type: 'list-view', objectName: OBJECT, viewType: 'grid', columns }; + render( + + + , + ); + return dataSource; +} + +async function selectFor(columns: string[]): Promise { + const dataSource = mount(columns); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + return [...((dataSource.find.mock.calls.at(-1)?.[1]?.$select as string[] | undefined) ?? [])].sort(); +} + +const isRed = (text: string) => screen.getByText(text).closest('span.tabular-nums')?.className.includes('text-red-600'); + +let prevGrid: any; +beforeAll(() => { + prevGrid = ComponentRegistry.get('object-grid'); + const DateCell = getCellRenderer('date'); + const def = objectDef.fields.deliver_by; + ComponentRegistry.register('object-grid', (props: any) => ( +
+ {(props.data ?? []).map((row: Record) => ( +
+ +
+ ))} +
+ ), { namespace: 'plugin-grid' } as never); +}); +afterAll(() => { + if (prevGrid) ComponentRegistry.register('object-grid', prevGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +beforeEach(() => { + vi.clearAllMocks(); + holder.current = makePerms(true, DENIED); +}); +afterEach(() => cleanup()); + +describe("ListView — a shown declared deadline projects its `settledWhen` operands (objectui#11815)", () => { + it('asks for `status` when the columns show the deadline but not `status`', async () => { + expect(await selectFor(['title', 'deliver_by'])).toEqual(['deliver_by', 'id', 'status', 'title']); + }); + + it("a done row's deadline reads neutral, an open row's reads overdue", async () => { + mount(['title', 'deliver_by']); + 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); + }); + + it('CONTROL: an undeclared date pulls nothing in, even with a `settledWhen` beside it', async () => { + expect(await selectFor(['title', 'reviewed_on'])).toEqual(['id', 'reviewed_on', 'title']); + }); + + it('CONTROL: an operand the viewer may not read is not requested', async () => { + expect(await selectFor(['title', 'gated_by'])).toEqual(['gated_by', 'id', 'title']); + }); +});