|
| 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