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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/11815-app-shell-approval-settled.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/11815-console-approval-settled.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/11815-list-settled-select.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<unknown> => (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, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
};
return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA };
});

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => I18N,
}));

vi.mock('@object-ui/auth', async (importOriginal) => {
const authFetch = vi.fn(async () => new Response('{}', { status: 200 }));
return {
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => AUTH,
createAuthenticatedFetch: () => authFetch,
TokenStorage: { get: () => null },
};
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
DeclaredActionsBar: () => null,
isViaOverrideRow: () => false,
}));

vi.mock('../../services/approvalsApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<string, unknown> {
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<string, unknown>): Promise<HTMLElement> {
approvalsApiStub.listRequests.mockResolvedValue({ data: [row], total: 1 });
approvalsApiStub.getRequest.mockResolvedValue({ data: row });
render(
<MePermissionsProvider initialPermissions={permissionsPayload()}>
<MemoryRouter initialEntries={[`/apps/${APP}/system/approvals?request=${String(row.id)}`]}>
<Routes>
<Route path="/apps/:appName/system/approvals" element={<ApprovalsInboxPage />} />
</Routes>
</MemoryRouter>
</MePermissionsProvider>,
);
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);
});
});
25 changes: 20 additions & 5 deletions apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
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 <Cell value={value} field={field as unknown as FieldMetadata} />;
return <Cell value={value} field={field as unknown as FieldMetadata} deadlineSettled={deadlineSettled} />;
}

/** One row of the drawer's summary card. `key` is the snapshot key, so it is unique. */
Expand Down Expand Up @@ -643,7 +656,7 @@ function payloadSummary(
out.push({
key: k,
label: declaredLabel(meta, k, def),
value: resolved ?? <RecordFieldFace objectName={meta.objectName} def={def} value={v} />,
value: resolved ?? <RecordFieldFace objectName={meta.objectName} def={def} value={v} snapshot={payload as Record<string, unknown>} fields={meta.defs} />,
title: resolved,
});
} else {
Expand Down Expand Up @@ -2462,6 +2475,8 @@ export function ApprovalsInboxPage() {
objectName={summaryMeta.objectName}
def={leadDef}
value={(selected.payload as Record<string, unknown>)[drawerAmount.key]}
snapshot={selected.payload as Record<string, unknown>}
fields={summaryMeta.defs}
/>
),
title: selected.payload_display?.[drawerAmount.key],
Expand Down
9 changes: 5 additions & 4 deletions content/docs/fields/date.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
fields: Record<string, FieldDef | undefined>;
}) {
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 <Cell value={value} field={field as unknown as FieldMetadata} />;
return <Cell value={value} field={field as unknown as FieldMetadata} deadlineSettled={deadlineSettled} />;
}

export interface RecordSnapshotSummaryProps {
Expand Down Expand Up @@ -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] ?? (
<FieldFace objectName={target} def={{ ...(fields[key] as FieldDef), name: key }} value={shown[key]} />
<FieldFace objectName={target} def={{ ...(fields[key] as FieldDef), name: key }} value={shown[key]} snapshot={shown} fields={fields} />
);

return (
Expand Down
Loading
Loading