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
9 changes: 9 additions & 0 deletions .changeset/12489-detail-section-deleted-when.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-detail': minor
---

The details grid passes a pointer field's `referenceDeletedWhen` to `resolveRecordPointer` (objectui#12489).

`DetailSection` hands the rule the object field's `referenceDeletedWhen` beside `referenceVia`, and the sibling field's options translated exactly as the grid translates a `select` field's options. So the tombstone a pointer face draws reads as the option reads in its own cell, localized labels included. The predicate is evaluated in `@object-ui/fields`, not here. A field without the key draws as before.

**Clause-②: yes (widening)**: the grid hands the pointer face one more state. No export, prop or type changes, and nothing narrows.
9 changes: 9 additions & 0 deletions .changeset/12489-pointer-card-tombstone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': minor
---

The record details grid draws a platform-deleted pointer target as a tombstone in the declared option's label (objectui#12489).

The pointer face registered under `RECORD_POINTER_CARD_TYPE` is the caller `RecordPreviewCard` leaves "a deletion the platform asserts" to. When the pair it receives carries `deletedLabel` (the field's `referenceDeletedWhen` held on this row), it draws that label beside a wastebasket in the card's frame (`data-record-pointer="deleted"`), and does not mount the card: no read of the target, no definition read, no link, no record id. On the served approval request page, a request whose `cancel_reason` is `record_deleted` now shows "Related record deleted", or the localized label of that option, where it showed "This record cannot be opened". A target read that fails or answers no record still draws the card's cause-free text: only the declared assertion means deleted.

**Clause-②: yes (widening)**: the face draws one more state. No export, prop, type or language-pack key changes, and nothing narrows.
11 changes: 11 additions & 0 deletions .changeset/12489-record-pointer-deleted-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/fields': minor
---

`resolveRecordPointer` reads `referenceDeletedWhen`, so a pointer pair can say the platform deleted its target (objectui#12489).

`@objectstack/spec`'s `FieldSchema.referenceDeletedWhen: { field, value }` sits beside `referenceVia` and names a sibling `select` field and one of its options. On a row whose sibling holds that option, the platform has asserted that the record the pointer names was deleted. `resolveRecordPointer(field, row, optionsOf)` now evaluates that predicate in the same call that resolves the pair. When it holds, the pair comes back with `deletedLabel`, the declared option's label taken from `optionsOf(field)`, the option list the call site draws `select` cells with. With no `optionsOf`, or no option with that value, the pair comes back as before. The spec refuses an undeclared option at parse, and the raw option value is never used as the text.

The default face under `RECORD_POINTER_CARD_TYPE` draws `deletedLabel` in place of the record id. A field without `referenceDeletedWhen`, or a row whose sibling holds anything else, draws exactly as before.

**Clause-②: yes (widening)**: the exported `RecordPointer` type gains the optional `deletedLabel`, `resolveRecordPointer`'s field parameter gains the optional `referenceDeletedWhen`, and the function gains an optional third parameter, `optionsOf`. Nothing narrows: every existing argument and return shape still type-checks and resolves as before. No new export.

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions content/docs/guide/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ record the reader cannot read is drawn with no link, and the card never says
why. Nothing is authored for this: the `referenceVia` declaration on the field
is the whole contract. List cells are not a call site yet.

When the platform deletes the record a pointer names, it can say so on the row.
A pointer field that also declares `referenceDeletedWhen: { field, value }`
names a sibling `select` field and the option the platform writes there, as
`sys_approval_request.record_id` declares
`{ field: 'cancel_reason', value: 'record_deleted' }`. On a row holding that
option the details grid draws a tombstone in the option's own label (localized
when the console's bundle localizes it), reads nothing and links nothing. A
record the card cannot read on any other row still gets the cause-free text:
only the declaration means deleted.

## Naming a registry item by name (`position-ref`, `permission-ref`)

A `text` field that stores the **machine name** of one item of a metadata
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* 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.
*/

/**
* The pointer face draws a platform-asserted deletion as a tombstone
* (objectui#12489).
*
* `RecordPreviewCard` leaves "a deletion the platform asserts" to its caller,
* and the pointer renderer is that caller. The real details grid
* (`DetailSection`) resolves the pair through `resolveRecordPointer`, which
* reads the field's `referenceDeletedWhen`; on a row that holds the declared
* option, this renderer draws the option's own label, reads nothing and links
* nothing.
*
* The object below declares `record_id` the way objectstack's `plugin-approvals`
* does: `referenceVia: 'object_name'` and
* `referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }`.
* Reads are counted on the adapter's own `findOne` spy; links under a host that
* builds record URLs, so a missing link is not just a missing builder.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
import { cleanup, render, waitFor } from '@testing-library/react';
import { AdapterCtx, MetadataCtx, RelatedRecordActionsProvider } from '@object-ui/react';
import { I18nProvider, type I18nConfig } from '@object-ui/i18n';
import { DetailSection } from '@object-ui/plugin-detail';
import type { DetailViewSection } from '@object-ui/types';
import './record-pointer-card-renderer';

beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }));
afterEach(() => cleanup());

const HOST_OBJECT = 'sys_approval_request';
const DELETED_LABEL = 'Related record deleted';

const REQUEST_SCHEMA = {
fields: {
object_name: { type: 'text', label: 'Object' },
record_id: {
type: 'text',
label: 'Record',
referenceVia: 'object_name',
referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' },
},
cancel_reason: {
type: 'select',
label: 'Cancel Reason',
options: [
{ value: 'record_deleted', label: DELETED_LABEL },
{ value: 'withdrawn', label: 'Withdrawn' },
],
},
},
};

const DEFS: Record<string, unknown> = {
invoice: {
name: 'invoice',
label: 'Invoice',
nameField: 'subject',
highlightFields: [],
fields: { subject: { type: 'text', label: 'Subject' } },
},
};

function mount(data: Record<string, unknown>, wrap: (ui: React.ReactElement) => React.ReactElement = (ui) => ui) {
const findOne = vi.fn(async (_objectName: string, id: string) => (id === 'inv_1' ? { id, subject: 'Laptop refresh' } : null));
const metadata = {
apps: [], objects: [], dashboards: [], reports: [], pages: [],
loading: false, error: null,
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
getItem: vi.fn(async (type: string, name: string) => (type === 'object' ? (DEFS[name] ?? null) : null)),
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};
const host = {
resolve: () => ({}),
recordHref: (objectName: string, recordId: string | number) =>
`/apps/probe_app/${objectName}/record/${encodeURIComponent(String(recordId))}`,
openRecord: vi.fn(),
};
const utils = render(
wrap(
<MetadataCtx.Provider value={metadata as never}>
<AdapterCtx.Provider value={{ findOne } as never}>
<RelatedRecordActionsProvider value={host}>
<DetailSection
section={{ title: 'Details', fields: [{ name: 'record_id', label: 'Record' }] } as DetailViewSection}
data={data}
objectSchema={REQUEST_SCHEMA}
objectName={HOST_OBJECT}
/>
</RelatedRecordActionsProvider>
</AdapterCtx.Provider>
</MetadataCtx.Provider>,
),
);
return { ...utils, findOne, getItem: metadata.getItem };
}

const DELETED_ROW = { object_name: 'invoice', record_id: 'inv_1', status: 'cancelled', cancel_reason: 'record_deleted' };

describe('the pointer face draws the platform-asserted deletion (objectui#12489)', () => {
it("draws the tombstone in the declared option's label", () => {
const { container } = mount(DELETED_ROW);
const tombstone = container.querySelector('[data-record-pointer="deleted"]');
expect(tombstone).not.toBeNull();
expect(tombstone?.textContent).toBe(DELETED_LABEL);
});

it('reads nothing: no record read and no definition read for the target', async () => {
const { findOne, getItem } = mount(DELETED_ROW);
// Give a read that would be made a chance to land before counting.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(findOne).not.toHaveBeenCalled();
expect(getItem).not.toHaveBeenCalled();
});

it('links nothing and does not mount the card, under a host that builds record links', () => {
const { container } = mount(DELETED_ROW);
expect(container.querySelectorAll('a[href]')).toHaveLength(0);
expect(container.querySelector('[data-record-preview]')).toBeNull();
});

it('does not show the record id inside the tombstone', () => {
const { container } = mount(DELETED_ROW);
const tombstone = container.querySelector('[data-record-pointer="deleted"]') as HTMLElement;
expect(tombstone.textContent).not.toContain('inv_1');
expect(tombstone.innerHTML).not.toContain('inv_1');
});

it("the label goes through the grid's option translation: a localized bundle names the tombstone", async () => {
const ZH: I18nConfig = {
defaultLanguage: 'zh-CN',
detectBrowserLanguage: false,
resources: {
'zh-CN': {
platform: {
objects: {},
fieldOptions: { [HOST_OBJECT]: { cancel_reason: { record_deleted: '关联记录已删除' } } },
},
},
},
};
const { container } = mount(DELETED_ROW, (ui) => (
<I18nProvider config={ZH} persistLanguage={false}>{ui}</I18nProvider>
));
await waitFor(() =>
expect(container.querySelector('[data-record-pointer="deleted"]')?.textContent).toBe('关联记录已删除'),
);
});

it('CONTROL: another option of the sibling draws the card and reads the target', async () => {
const { container, findOne } = mount({ ...DELETED_ROW, cancel_reason: 'withdrawn' });
await waitFor(() =>
expect(container.querySelector('[data-record-preview]')?.getAttribute('data-record-preview')).toBe('readable'),
);
expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull();
expect(findOne.mock.calls.map((call) => call.slice(0, 2))).toEqual([['invoice', 'inv_1']]);
});

it('CONTROL: an unreadable target on a row the platform did not flag stays cause-free, with no tombstone', async () => {
const { container } = mount({ object_name: 'invoice', record_id: 'inv_404', status: 'approved' });
await waitFor(() =>
expect(container.querySelector('[data-record-preview]')?.getAttribute('data-record-preview')).toBe('unreadable'),
);
expect(container.querySelector('[data-record-pointer="deleted"]')).toBeNull();
expect(container.textContent).not.toContain(DELETED_LABEL);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,57 @@
* The card reads the target through the console's adapter, the same instance
* every other record read on the page goes through. It says nothing about why
* a target cannot be read; see the card's own header for why that matters.
*
* ## The tombstone: this renderer is the caller the card defers to (objectui#12489)
*
* The card's header leaves "a deletion the platform asserts" to its caller.
* This renderer is that caller. When the pair arrives with `deletedLabel`
* (the field's `referenceDeletedWhen` predicate held on this row, decided by
* `resolveRecordPointer`), it draws the tombstone in that label and does not
* mount the card: no read of the target, no link. The text is the declared
* option's own label, as the details grid's options spell it; this package
* authors no string for it. Every other pair draws the card exactly as before.
*/

import { Card, CardContent } from '@object-ui/components';
import { registerFieldRenderer, RECORD_POINTER_CARD_TYPE, type CellRendererProps, type RecordPointer } from '@object-ui/fields';
import { useAdapter } from '@object-ui/react';
import { Trash2 } from 'lucide-react';
import { RecordPreviewCard } from './RecordPreviewCard.js';

/** The pair `resolveRecordPointer` hands the renderer as its `value`. */
function asRecordPointer(value: unknown): RecordPointer | null {
if (value == null || typeof value !== 'object') return null;
const { objectName, recordId } = value as Partial<RecordPointer>;
return typeof objectName === 'string' && typeof recordId === 'string' ? { objectName, recordId } : null;
const { objectName, recordId, deletedLabel } = value as Partial<RecordPointer>;
if (typeof objectName !== 'string' || typeof recordId !== 'string') return null;
return typeof deletedLabel === 'string' ? { objectName, recordId, deletedLabel } : { objectName, recordId };
}

/**
* The tombstone for a target the platform asserted deleted: the declared
* option's label, beside a wastebasket, in the card's frame. It reads nothing
* and links nothing, and it does not show the record id.
*/
function DeletedRecordPointer({ label }: { label: string }) {
return (
<Card data-record-pointer="deleted" className="w-full max-w-md min-w-0 shadow-none">
<CardContent className="flex items-center gap-1.5 p-3 text-sm text-muted-foreground" title={label}>
<Trash2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate italic">{label}</span>
</CardContent>
</Card>
);
}

/**
* Draws the record a pointer pair names as `RecordPreviewCard`. A value that is
* not a pair addresses nothing, and the card draws its `absent` state for it.
* Draws the record a pointer pair names as `RecordPreviewCard`, or the
* tombstone when the pair says the platform deleted the target. A value that
* is not a pair addresses nothing, and the card draws its `absent` state for it.
*/
export function RecordPointerCardRenderer({ value }: CellRendererProps) {
const dataSource = useAdapter();
const pointer = asRecordPointer(value);
if (pointer?.deletedLabel !== undefined) return <DeletedRecordPointer label={pointer.deletedLabel} />;
return (
<RecordPreviewCard
objectName={pointer?.objectName}
Expand Down
13 changes: 10 additions & 3 deletions packages/fields/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,16 @@ with `referenceVia: 'object_name'`, or `sys_activity.source_id` with
renderer gets one value and no row, so the pair is resolved by the caller that
holds the row (objectui#12045):

- `resolveRecordPointer(field, row)` is the one place the rule lives. It
returns `{ objectName, recordId }` (a `RecordPointer`) for a `text` field with
a non-empty `referenceVia` whose row fills both halves, and `null` otherwise.
- `resolveRecordPointer(field, row, optionsOf?)` is the one place the rule
lives. It returns `{ objectName, recordId }` (a `RecordPointer`) for a `text`
field with a non-empty `referenceVia` whose row fills both halves, and `null`
otherwise.
- When the field also declares `referenceDeletedWhen: { field, value }` (the
platform's assertion that it deleted the target, objectui#12489) and the row's
sibling `field` holds `value`, the pair carries `deletedLabel`: that option's
label from `optionsOf(field)`, the options the caller draws `select` cells
with. A face draws it as a tombstone and reads and links nothing; the default
face draws it in place of the record id.
- `RECORD_POINTER_CARD_TYPE` is the registry key a resolved pair is drawn with,
the pair as the renderer's `value`. This package registers a default under it
that draws the record id as text, so nothing changes for a host that does not
Expand Down
Loading
Loading