Skip to content

Commit 60ccda5

Browse files
feat(example-showcase): host record_header / record_more / record_related for showcase_task (#22036)
Fixes #22030 Clause-②: no The showcase now hosts the three record action locations for `showcase_task` on stock pages, so `records-forms.action-location-matrix` A1 (record_header / record_more / record_related) and A3 (the url and api dispatch from the ⋯ overflow) can be run on a stock showcase boot. This is a fixture change in `examples/app-showcase` only: no product code, no checklist edit, and no new action. ## What changed | Location | Host | Actions it shows (`showcase_task`) | |---|---|---| | `record_header` | Task Detail (`showcase_task_detail`): a new `page:header` node, first in the page | `showcase_mark_done` (script), `showcase_log_time` (form), `showcase_archive_task` (script), as inline buttons | | `record_more` | the same header's ⋯ overflow | `showcase_open_docs` (url), `showcase_recalc_estimate` (api), `showcase_recalc_selection` (api) | | `record_related` | Project Detail (`showcase_project_detail`): a new `related` tab with the Tasks `record:related_list` | `showcase_log_time` on each row (beside the `list_item` actions) | - `src/ui/pages/task-detail.page.ts`: adds `page:header` with `actions` set to the task's six `record_header` / `record_more` action ids, which is the set the synthesized header would carry. No `title`, so the heading still comes from the record. The host's Edit / Share / Delete (order 100 to 120) still append after the authored actions. - `src/ui/pages/project-detail.page.ts`: keeps the `tasks` tab and its `record:line_items` grid unchanged. Adds a third tab, `related`, with `record:related_list` for `showcase_task` through `project`. It has no `actions` key, so the rows carry the host bridge's actions. - `src/ui/actions/index.ts`: comment only. The note on `showcase_reopen_task` said the Task Detail page "renders no header action bar". That is no longer true, so the comment now points to the header's id list and to the pin that holds it. - `test/record-action-location-hosts.test.ts` (new, 9 cases) pins both hosts on the page the console picks for each object. It also holds the authored lists equal to the declarations they copy: the header ids against the task's `record_header` / `record_more` set, and the related list's `title` / `columns` against the FK's `relatedListTitle` / `relatedListColumns`. - `test/project-detail-tabs.test.ts`: the tab strip is now `details, tasks, related` (3 items, was 2). ## Why this shape (measured at the objectui pin `a58626c88dc8`) - **The hosts must sit on the existing pages.** `usePageAssignment` renders the FIRST `type: 'record'` page bound to an object, in declaration order, so a second page for either object would never be reached. - **A `kind: 'full'` page renders only what it declares** (objectui#7298 ruling, `RecordDetailView`). With no `page:header` node, the page had no title bar. `PageHeaderProps.actions` is spec-declared (`z.array(z.string())`, "Action IDs to show in header"). The renderer (`containers.tsx`, `resolvedHeaderActions`) looks the ids up in the object's metadata and places each one by its `locations`: `record_header` actions go inline, and an action that declares `record_more` without `record_header` always goes under ⋯. `os validate` (`validate-action-name-refs`) checks the same ids. - **The `tabs` slot is a full replacement**, so overriding it removed the synthesized related-list strip. `showcase_task.project` is the task's only parent relationship. `RelatedRecordActionsBridge` wraps the whole record-page body, authored or synthesized, and places `list_item` + `record_related` on the rows of any `record:related_list` under a parent record (`ROW_LOCATIONS_IN_RECORD`). For an authored node with no `actions`, the record-related-list renderer leaves those host actions untouched. - **Alternative considered and not taken:** moving project-detail's override from `tabs` to `details`, which would keep the synthesized strip itself. It would remove the authored `page:tabs` that the #5776 pin exists for, drop the `?tab=tasks` token, and move the editable grid into the Details tab. - **Deviation from the card's wording:** the card asks for "the synthesized Tasks related list". The list here is an authored `record:related_list` node, the same component and the same bridge as the synthesizer's output, with `title` / `columns` copied from the FK (only the synthesizer reads `relatedListTitle` / `relatedListColumns`). The follow-up knownGap lift should say "the Tasks related list". ## Guard: `record:line_items` stayed It was not removed from project-detail. `git grep -n 'record:line_items'` over `docs/qa/platform-checklist` and `examples/app-showcase/test` at HEAD: - `docs/qa/platform-checklist/areas/records-forms.json:1547` and `:1661` (this item's knownGap and its revision-7 history row, both describing the gap) - `examples/app-showcase/test/project-detail-line-items.test.ts:13, :22, :56, :89` (the #21142 column pin, still green) - `examples/app-showcase/test/record-action-location-hosts.test.ts:20` (this PR's docblock) ## Verification (HEAD `3ae68e530c`) - **Showcase suite:** `pnpm --filter @objectstack/example-showcase test` exit 0, 33 files / 408 tests. - **The new and updated test files, verbose:** 13/13 green. - **`validate` and `typecheck`:** `pnpm --filter @objectstack/example-showcase validate` exit 0 ("Validation passed"). None of its 72 warnings names either page, `showcase_task.*` or `relatedList*`. `typecheck` exit 0; `tsc --listFiles` includes both pages and both test files. - **Ablation** (one-time, `scripts/ablation-replace.mjs` WRAP mode, every leg restored with blob == HEAD and an empty `git diff HEAD`): - leg 1, task header retyped to `page:card`: 4 failed / 5 passed; - leg 2, related list retyped to `record:highlights`: 4 failed / 5 passed (re-run after hardening two cases that passed vacuously the first time, 2 failed / 7 passed); - leg 3, `record_header` added to `showcase_reopen_task` without adding it to the header: 1 failed, the set-equality case, naming `showcase_reopen_task`. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` on the actual diff derived 42 commands. All 42 ran, each with a recorded exit code, and all exit 0. `--ran`: "42 derived famil(ies) accounted for — 42 run, 0 NOT-MEASURED (a DERIVED zero)". `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (8 packages unbuilt); it was re-run after building them and answered exit 0. Named in the dispatch and green: `check:page-declaration-shape`, `check:examples-live-imports`, `check:i18n-coverage` ("none new"), `check:react-page-adapter-contract`, `check:cross-package-test-inputs`, `check:org-identifier`, `check-undeclared-dep-imports` (+ `--self-test`), `check:nul-bytes`. - **Lint, narrowed:** eslint `--no-inline-config --format json` over the 5 changed files: 5 files, 0 errors, 0 warnings. These files fall under the config block for `examples/**/*.{ts,tsx,mts,cts}`. The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. The full `pnpm lint` run is CI's. - **Live boot** (backend only, `pnpm dev:showcase -- --fresh` on a random port, torn down afterwards): from the served `GET /api/v1/meta/page` list and `GET /api/v1/meta/object/showcase_task`: - record pages are served in the order `showcase_project_detail`, then `showcase_task_detail`, each the first for its object; - all six header ids match a served action, and they equal the served `record_header` / `record_more` set. Three are inline-eligible (script, form, script) and three are overflow-only (url, api, api); - project-detail serves the tabs `details, tasks, related`, with `record:line_items` kept; - `record_related` on the task is `showcase_log_time`, and a seeded project has 2 tasks. - **NOT MEASURED: the browser render of the two surfaces.** No console bundle exists in any checkout here, and building one would mean a full objectui build at the pin. The rendering claims above are read from objectui source at the pin. ## Acceptance notes - The knownGap lift on `records-forms.action-location-matrix` is a follow-up that cites this PR, after this PR and #22029 are both on `main`. #22029 holds `records-forms.json`, and this PR does not touch it. - `packages/spec/src/ui/component.test.ts` cites `task-detail.page.ts:40` and `:76` in comments. Both were already stale at the base (`:76` read `location: 'record_section'`), and this diff shifts them by a further 35 lines. Comment drift only: noted here, not filed. Carrier: none. - The new tab label `Related` and list title `Tasks` have no zh-CN entry. The same is true of the existing `Details` / `Tasks` tab labels: slot content is outside the extractor's walk, and `check:i18n-coverage` reports none new. - `showcase_task.project`'s comment ("Tasks are added later from the Project DETAIL page's Tasks related list") was untrue on `main` and is true again with this PR. ## Labels `skip-changeset`: `@objectstack/example-showcase` is `private` and publishes nothing. --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47f6355 commit 60ccda5

5 files changed

Lines changed: 248 additions & 14 deletions

File tree

‎examples/app-showcase/src/ui/actions/index.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,11 @@ export const ReopenTaskAction = defineAction({
114114
execution: 'perRecord',
115115
// #8990 — `has()` guards the sparse `list_item` face, as on Mark Done.
116116
visible: 'has(record.done) && record.done == true',
117-
// The task list's row menu only. `record_header` would be inert here: the
118-
// Task Detail page (`showcase_task_detail`, `kind: 'full'`) owns the whole
119-
// record layout and renders no header action bar.
117+
// The task list's row menu only. The Task Detail page's header lists its
118+
// action ids explicitly (`showcase_task_detail`, `kind: 'full'`), and
119+
// `test/record-action-location-hosts.test.ts` holds that list equal to the
120+
// task's `record_header` / `record_more` set, so adding `record_header` here
121+
// means adding the id to that header too.
120122
locations: ['list_item'],
121123
refreshAfter: true,
122124
});

‎examples/app-showcase/src/ui/pages/project-detail.page.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import { definePage } from '@objectstack/spec/ui';
44

55
/**
66
* Project detail — a slotted record page that surfaces the project's Tasks as
7-
* an INLINE-EDITABLE `record:line_items` grid (ObjectUI ADR-0001), instead of
8-
* a read-only related list. This is the "view + edit the children together"
9-
* half of the master-detail story: open a project, edit its tasks in place,
10-
* and Save persists the diff (create/update/delete) with the `master_detail`
11-
* FK maintained.
7+
* an INLINE-EDITABLE `record:line_items` grid (ObjectUI ADR-0001), beside the
8+
* read-only Tasks related list. The grid is the "view + edit the children
9+
* together" half of the master-detail story: open a project, edit its tasks in
10+
* place, and Save persists the diff (create/update/delete) with the
11+
* `master_detail` FK maintained.
1212
*
1313
* `kind: 'slotted'` with only the `tabs` slot overridden — the synthesizer
14-
* fills in the header / highlights / details / discussion; the Tasks tab below
15-
* replaces the synthesized related-list strip.
14+
* fills in the header / highlights / details / discussion. Overriding `tabs`
15+
* replaces the whole synthesized tab strip (a slot is a full replacement, with
16+
* no merge), and that strip is where the synthesized related lists live — so
17+
* the page puts the Tasks related list back itself, in a Related tab (below).
1618
*/
1719
export const ProjectDetailPage = definePage({
1820
name: 'showcase_project_detail',
@@ -107,6 +109,41 @@ export const ProjectDetailPage = definePage({
107109
},
108110
],
109111
},
112+
{
113+
// The read side of the same relationship: the Tasks related list,
114+
// as the synthesized strip would draw it for a non-primary
115+
// relationship (a `Related` tab, `?tab=related`). The `tabs`
116+
// override above removed that strip, and `showcase_task.project`
117+
// is the task's only parent relationship, so without this tab no
118+
// record page in the showcase shows tasks as a related list.
119+
//
120+
// It is the same `record:related_list` node the synthesizer emits,
121+
// under the same record page, so its rows carry what a synthesized
122+
// list's rows carry: the task's `list_item` actions, plus its
123+
// `record_related` ones, which only render on a related list
124+
// inside a parent record. No `actions` key on purpose — authoring
125+
// one would replace those host-supplied actions with the list
126+
// named here.
127+
//
128+
// `title` and `columns` repeat what the FK declares
129+
// (`relatedListTitle` / `relatedListColumns` on
130+
// `showcase_task.project`): only the synthesizer reads those keys,
131+
// and an authored node is not synthesized.
132+
// `test/record-action-location-hosts.test.ts` holds the two equal.
133+
value: 'related',
134+
label: 'Related',
135+
children: [
136+
{
137+
type: 'record:related_list',
138+
properties: {
139+
objectName: 'showcase_task',
140+
relationshipField: 'project',
141+
title: 'Tasks',
142+
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
143+
},
144+
},
145+
],
146+
},
110147
],
111148
},
112149
},

‎examples/app-showcase/src/ui/pages/task-detail.page.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { definePage } from '@objectstack/spec/ui';
55
/**
66
* Task Detail — a record page that exercises the record-layout component set
77
* beyond the basics:
8+
* • `page:header` — the record title bar: the task's `record_header`
9+
* actions inline and its `record_more` actions
10+
* under the ⋯ overflow (see the node below).
811
* • `record:path` — Salesforce-style status stepper across the task
912
* lifecycle (Backlog → … → Done).
1013
* • `record:alert` — a conditional banner shown only while the task is
@@ -29,6 +32,38 @@ export const TaskDetailPage = definePage({
2932
name: 'main',
3033
width: 'full',
3134
components: [
35+
// The title bar, composed explicitly. A `kind: 'full'` page renders
36+
// exactly the nodes it declares, and the header is one of them: with
37+
// no `page:header` node the page has no title bar, so neither the
38+
// `record_header` nor the `record_more` location has a surface here.
39+
// The synthesized default page (and a `kind: 'slotted'` page that
40+
// leaves the `header` slot alone) gets one automatically; this page
41+
// does not.
42+
//
43+
// `actions` are action IDS, resolved against `showcase_task`'s own
44+
// metadata, and the header still places each by its `locations`:
45+
// `record_header` actions render as inline buttons, and an action
46+
// declaring `record_more` without `record_header` always goes under
47+
// the ⋯ overflow. The list is the object's whole `record_header` /
48+
// `record_more` set, which is what the synthesized header would carry;
49+
// `test/record-action-location-hosts.test.ts` holds the two equal.
50+
// The host still appends its own Edit / Share / Delete after these.
51+
// No `title`: the header derives it from the record.
52+
{
53+
type: 'page:header',
54+
properties: {
55+
actions: [
56+
// record_header — inline buttons.
57+
'showcase_mark_done',
58+
'showcase_log_time',
59+
'showcase_archive_task',
60+
// record_more only — the ⋯ overflow (url, api, api).
61+
'showcase_open_docs',
62+
'showcase_recalc_estimate',
63+
'showcase_recalc_selection',
64+
],
65+
},
66+
},
3267
{
3368
type: 'record:path',
3469
properties: {

‎examples/app-showcase/test/project-detail-tabs.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,15 @@ const allPages = (Object.values(pages) as unknown[]).filter(
7272
) as Record<string, unknown>[];
7373

7474
describe('Project detail — tab tokens are authored under the declared `value` (#5776)', () => {
75-
it('gives both tabs a stable `?tab=` token under `value`', () => {
75+
it('gives every tab a stable `?tab=` token under `value`', () => {
7676
const [tabs, ...rest] = tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>);
7777
expect(tabs, 'the project-detail page must carry a page:tabs component').toBeTruthy();
7878
expect(rest, 'exactly one tab strip on this page').toHaveLength(0);
7979

8080
const items = tabItems(tabs);
81-
expect(items).toHaveLength(2);
82-
expect(items.map((it) => it.value)).toEqual(['details', 'tasks']);
81+
// `related` hosts the Tasks related list (record-action-location-hosts.test.ts).
82+
expect(items).toHaveLength(3);
83+
expect(items.map((it) => it.value)).toEqual(['details', 'tasks', 'related']);
8384
for (const item of items) {
8485
expect(item, `tab "${String(item.label)}" must not carry the undeclared \`key\``).not.toHaveProperty('key');
8586
}
@@ -92,7 +93,7 @@ describe('Project detail — tab tokens are authored under the declared `value`
9293
// `value`.
9394
const tabs = tabsComponents(ProjectDetailPage as unknown as Record<string, unknown>)[0]!;
9495
const parsed = PageTabsProps.parse(tabs.properties);
95-
expect(parsed.items.map((it) => it.value)).toEqual(['details', 'tasks']);
96+
expect(parsed.items.map((it) => it.value)).toEqual(['details', 'tasks', 'related']);
9697
});
9798

9899
it('leaves no near-miss token spelling on any tab item in the showcase corpus', () => {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { PageHeaderProps, RecordRelatedListProps } from '@objectstack/spec/ui';
5+
6+
import stack from '../objectstack.config.js';
7+
import { Task } from '../src/data/objects/task.object.js';
8+
9+
/**
10+
* Fixture gate: the showcase hosts the three record action locations for
11+
* `showcase_task` (objectstack#22030).
12+
*
13+
* `records-forms.action-location-matrix` reads actions at `record_header`,
14+
* `record_more` and `record_related` on `showcase_task`. A location needs a
15+
* surface on a page the console actually renders, and the showcase had none:
16+
*
17+
* - Task Detail is `kind: 'full'`. Such a page renders exactly the nodes it
18+
* declares, and it declared no `page:header`, so there was no title bar for
19+
* `record_header` actions and no ⋯ overflow for `record_more` ones.
20+
* - Project Detail overrides the `tabs` slot with a `record:line_items` grid.
21+
* That slot is a full replacement, so the synthesized Tasks related list
22+
* went with it, and `record_related` actions render only on the rows of a
23+
* related list inside a parent record.
24+
*
25+
* The assertions pin each host on the page the console picks for the object,
26+
* and hold the authored lists equal to the declarations they copy, so a new
27+
* action or a changed FK cannot leave a host silently stale.
28+
*/
29+
30+
type AnyComponent = {
31+
type?: unknown;
32+
properties?: Record<string, unknown>;
33+
[k: string]: unknown;
34+
};
35+
type AnyPage = Record<string, unknown> & { name?: unknown; type?: unknown; object?: unknown };
36+
type AnyAction = { name?: unknown; type?: unknown; objectName?: unknown; locations?: unknown };
37+
38+
/** Every component on a page — regions and slots alike, tab panels and children included. */
39+
function allComponents(page: AnyPage): AnyComponent[] {
40+
const out: AnyComponent[] = [];
41+
const visit = (node: unknown): void => {
42+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
43+
const component = node as AnyComponent;
44+
out.push(component);
45+
const props = component.properties;
46+
if (!props) return;
47+
for (const item of Array.isArray(props.items) ? props.items : []) {
48+
const children = (item as { children?: unknown })?.children;
49+
for (const child of Array.isArray(children) ? children : []) visit(child);
50+
}
51+
for (const child of Array.isArray(props.children) ? props.children : []) visit(child);
52+
};
53+
for (const region of (page.regions as { components?: unknown[] }[] | undefined) ?? []) {
54+
for (const c of region.components ?? []) visit(c);
55+
}
56+
for (const slot of Object.values((page.slots as Record<string, unknown>) ?? {})) {
57+
for (const c of Array.isArray(slot) ? slot : [slot]) visit(c);
58+
}
59+
return out;
60+
}
61+
62+
const pages = (stack.pages ?? []) as AnyPage[];
63+
64+
/**
65+
* The record page the console renders for an object: the FIRST `type: 'record'`
66+
* page bound to it, in declaration order (objectui `usePageAssignment`). A
67+
* second page for the same object would never be reached, so the hosts must sit
68+
* on this one.
69+
*/
70+
const recordPageFor = (object: string): AnyPage | undefined =>
71+
pages.find((p) => p.type === 'record' && p.object === object);
72+
73+
const taskActions = ((stack.actions ?? []) as AnyAction[]).filter((a) => a.objectName === Task.name);
74+
const locationsOf = (a: AnyAction): string[] =>
75+
Array.isArray(a.locations) ? (a.locations as string[]) : [];
76+
const declares = (location: string) => (a: AnyAction): boolean => locationsOf(a).includes(location);
77+
const actionNamed = (name: string): AnyAction | undefined => taskActions.find((a) => a.name === name);
78+
79+
describe('showcase_task record action locations have a stock host (#22030)', () => {
80+
const taskPage = recordPageFor(Task.name);
81+
const projectPage = recordPageFor('showcase_project');
82+
83+
it('the console picks Task Detail for a task and Project Detail for a project', () => {
84+
expect(taskPage?.name).toBe('showcase_task_detail');
85+
expect(projectPage?.name).toBe('showcase_project_detail');
86+
});
87+
88+
describe('record_header / record_more — the Task Detail title bar', () => {
89+
const headers = taskPage ? allComponents(taskPage).filter((c) => c.type === 'page:header') : [];
90+
const ids = (headers[0]?.properties?.actions ?? []) as unknown[];
91+
92+
it('composes exactly one page:header, first in the page', () => {
93+
expect(headers).toHaveLength(1);
94+
const firstRegion = (taskPage?.regions as { components?: unknown[] }[] | undefined)?.[0];
95+
expect(firstRegion?.components?.[0]).toBe(headers[0]);
96+
});
97+
98+
it('parses against the spec\'s `page:header` row, with its action ids intact', () => {
99+
const result = PageHeaderProps.safeParse(headers[0]?.properties);
100+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
101+
expect(result.data?.actions).toEqual(ids);
102+
});
103+
104+
it('lists the task\'s whole record_header / record_more set — what the synthesized header carries', () => {
105+
expect(ids.every((id) => typeof id === 'string')).toBe(true);
106+
expect(new Set(ids).size, 'no id is listed twice').toBe(ids.length);
107+
const headerSet = taskActions
108+
.filter((a) => declares('record_header')(a) || declares('record_more')(a))
109+
.map((a) => a.name as string);
110+
expect([...ids].sort()).toEqual([...headerSet].sort());
111+
});
112+
113+
it('hosts at least one action at each location, and a url and an api action under the ⋯ overflow', () => {
114+
const listed = ids.map((id) => actionNamed(id as string)).filter((a): a is AnyAction => !!a);
115+
expect(listed).toHaveLength(ids.length);
116+
expect(listed.filter(declares('record_header')).length).toBeGreaterThan(0);
117+
// The overflow takes every action declaring record_more WITHOUT
118+
// record_header, whatever the inline budget.
119+
const overflowOnly = listed.filter((a) => declares('record_more')(a) && !declares('record_header')(a));
120+
expect(overflowOnly.map((a) => a.type)).toEqual(expect.arrayContaining(['url', 'api']));
121+
});
122+
});
123+
124+
describe('record_related — the Tasks related list on a project', () => {
125+
const relatedLists = projectPage
126+
? allComponents(projectPage).filter(
127+
(c) => c.type === 'record:related_list' && c.properties?.objectName === Task.name,
128+
)
129+
: [];
130+
const props = relatedLists[0]?.properties ?? {};
131+
const fk = (Task.fields as Record<string, Record<string, unknown>>)[props.relationshipField as string];
132+
133+
it('carries one Tasks related list, bound through the task\'s master-detail FK to the project', () => {
134+
expect(relatedLists).toHaveLength(1);
135+
expect(props.relationshipField).toBe('project');
136+
expect(fk?.type).toBe('master_detail');
137+
expect(fk?.reference).toBe('showcase_project');
138+
});
139+
140+
it('authors no `actions`, so its rows carry the host\'s list_item + record_related actions', () => {
141+
expect(relatedLists).toHaveLength(1);
142+
expect(props).not.toHaveProperty('actions');
143+
expect(taskActions.filter(declares('record_related')).length).toBeGreaterThan(0);
144+
});
145+
146+
it('repeats the FK\'s relatedListTitle / relatedListColumns verbatim', () => {
147+
// Both sides must exist: two absent values would compare equal.
148+
expect(typeof fk?.relatedListTitle).toBe('string');
149+
expect(Array.isArray(fk?.relatedListColumns)).toBe(true);
150+
expect(props.title).toBe(fk?.relatedListTitle);
151+
expect(props.columns).toEqual(fk?.relatedListColumns);
152+
});
153+
154+
it('parses against the spec\'s `record:related_list` row', () => {
155+
const result = RecordRelatedListProps.safeParse(props);
156+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
157+
});
158+
});
159+
});

0 commit comments

Comments
 (0)