|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #22537 — `record:approvals` and `record:attachments`, the record page's |
| 5 | + * Approvals and Attachments panels, through the authoring rules `os validate` / |
| 6 | + * `os build` / `os lint` run. objectui registered both inside the spec-reserved |
| 7 | + * `record` namespace with no `PageComponentType` member and no |
| 8 | + * `ComponentPropsMap` row, so `component-type-unknown` refused the page Studio's |
| 9 | + * page create seeds for any `enable.files` object: the seed's Attachments tab |
| 10 | + * holds a bare `record:attachments`. The spec-side pins (the rows, the enum |
| 11 | + * members, the print classification) live beside the rows in |
| 12 | + * `@objectstack/spec`. |
| 13 | + */ |
| 14 | +import { describe, expect, it } from 'vitest'; |
| 15 | +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; |
| 16 | + |
| 17 | +import { runAuthoringRules } from './authoring-rules.js'; |
| 18 | +import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; |
| 19 | +import { COMPONENT_TYPE_UNKNOWN, validateComponentTypes } from './validate-component-types.js'; |
| 20 | + |
| 21 | +type AnyRec = Record<string, unknown>; |
| 22 | + |
| 23 | +const OBJECT = { |
| 24 | + name: 'invoice', |
| 25 | + label: 'Invoice', |
| 26 | + enable: { files: true }, |
| 27 | + sharingModel: 'private', |
| 28 | + fields: { |
| 29 | + name: { type: 'text', label: 'Name' }, |
| 30 | + amount: { type: 'number', label: 'Amount' }, |
| 31 | + }, |
| 32 | +}; |
| 33 | + |
| 34 | +/** |
| 35 | + * The page Studio's page create stores for a `type: 'record'` page bound to |
| 36 | + * `invoice`: the `regions` and `template` of objectui's |
| 37 | + * `buildDefaultPageSchema(objectDef)`, called with no options (objectui |
| 38 | + * `app-shell/src/views/metadata-admin/anchors.ts`, `createSeed`). The |
| 39 | + * Attachments tab is the synthesizer's `buildDefaultAttachments()`, a bare |
| 40 | + * `{ type: 'record:attachments' }` at `components[2].properties.items[1]`. |
| 41 | + */ |
| 42 | +const seededRecordPage = (extraTabs: unknown[] = []): AnyRec => ({ |
| 43 | + name: 'invoice_record', |
| 44 | + label: 'Invoice', |
| 45 | + type: 'record', |
| 46 | + object: 'invoice', |
| 47 | + kind: 'full', |
| 48 | + template: 'full-width', |
| 49 | + regions: [ |
| 50 | + { |
| 51 | + name: 'main', |
| 52 | + width: 'full', |
| 53 | + components: [ |
| 54 | + { type: 'page:header', properties: { recordChrome: true } }, |
| 55 | + { type: 'record:highlights', properties: { fields: ['amount'] } }, |
| 56 | + { |
| 57 | + type: 'page:tabs', |
| 58 | + properties: { |
| 59 | + items: [ |
| 60 | + { label: 'Details', value: 'details', children: [{ type: 'record:details', properties: { hideFields: ['amount'] } }] }, |
| 61 | + { label: 'Attachments', value: 'attachments', children: [{ type: 'record:attachments' }] }, |
| 62 | + ...extraTabs, |
| 63 | + ], |
| 64 | + }, |
| 65 | + }, |
| 66 | + { type: 'record:discussion' }, |
| 67 | + ], |
| 68 | + }, |
| 69 | + ], |
| 70 | +}); |
| 71 | + |
| 72 | +const approvalsTab = (node: AnyRec) => ({ label: 'Approvals', value: 'approvals', children: [node] }); |
| 73 | +/** One more authored tab holding `node`, at `items[2]`. */ |
| 74 | +const extraTab = (node: AnyRec) => ({ label: 'Panel', value: 'panel', children: [node] }); |
| 75 | + |
| 76 | +const stackOf = (page: AnyRec): AnyRec => ({ |
| 77 | + manifest: { id: 'com.example.invoices', name: 'invoices', version: '1.0.0', type: 'app' }, |
| 78 | + objects: [OBJECT], |
| 79 | + pages: [page], |
| 80 | +}); |
| 81 | + |
| 82 | +/** |
| 83 | + * The verdict `os validate` reaches on a config: the stack parse, then the |
| 84 | + * shared rule pipeline, with an `error` finding as the refusal (the CLI's |
| 85 | + * `judgeAuthorTimeRules`, minus the JSX gate and the per-package pass, which a |
| 86 | + * config with no `kind: 'jsx'` page and no `packages` never reaches). |
| 87 | + */ |
| 88 | +const validateVerdict = (page: AnyRec) => { |
| 89 | + const normalized = normalizeStackInput(stackOf(page)) as AnyRec; |
| 90 | + const parsed = ObjectStackDefinitionSchema.safeParse(normalized); |
| 91 | + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues.slice(0, 3))).toBe(true); |
| 92 | + const findings = runAuthoringRules('validate', { normalized: normalized as never, parsed: parsed.data as never }); |
| 93 | + return { |
| 94 | + errors: findings.filter((f) => f.severity === 'error'), |
| 95 | + componentFindings: findings.filter((f) => f.rule === COMPONENT_TYPE_UNKNOWN || f.rule.startsWith('component-props')), |
| 96 | + }; |
| 97 | +}; |
| 98 | + |
| 99 | +const TABS = 'pages[0].regions[0].components[2].properties.items'; |
| 100 | + |
| 101 | +describe("the page Studio's page create seeds passes the authoring doors", () => { |
| 102 | + it('with the Attachments tab the synthesizer emits for an `enable.files` object', () => { |
| 103 | + const page = seededRecordPage(); |
| 104 | + expect(validateComponentTypes({ pages: [page] })).toEqual([]); |
| 105 | + expect(validateComponentProps({ pages: [page] })).toEqual([]); |
| 106 | + const verdict = validateVerdict(page); |
| 107 | + expect(verdict.errors).toEqual([]); |
| 108 | + expect(verdict.componentFindings).toEqual([]); |
| 109 | + }); |
| 110 | + |
| 111 | + it('with an authored Approvals tab placing a bare `record:approvals`', () => { |
| 112 | + const page = seededRecordPage([approvalsTab({ type: 'record:approvals' })]); |
| 113 | + expect(validateComponentTypes({ pages: [page] })).toEqual([]); |
| 114 | + expect(validateComponentProps({ pages: [page] })).toEqual([]); |
| 115 | + const verdict = validateVerdict(page); |
| 116 | + expect(verdict.errors).toEqual([]); |
| 117 | + expect(verdict.componentFindings).toEqual([]); |
| 118 | + }); |
| 119 | + |
| 120 | + it('control: the same seed with a misspelled type in the reserved namespace is refused', () => { |
| 121 | + const page = seededRecordPage([approvalsTab({ type: 'record:aprovals' })]); |
| 122 | + const verdict = validateVerdict(page); |
| 123 | + expect(verdict.errors.map((f) => [f.rule, f.path])).toEqual([ |
| 124 | + [COMPONENT_TYPE_UNKNOWN, `${TABS}[2].children[0].type`], |
| 125 | + ]); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +describe('a misspelled type offers the declared spelling', () => { |
| 130 | + it.each([ |
| 131 | + ['record:attachment', 'record:attachments'], |
| 132 | + ['record:aprovals', 'record:approvals'], |
| 133 | + ])('`%s` is `component-type-unknown`, offering `%s`', (typo, declared) => { |
| 134 | + const findings = validateComponentTypes({ pages: [seededRecordPage([extraTab({ type: typo })])] }); |
| 135 | + expect(findings).toHaveLength(1); |
| 136 | + const [f] = findings; |
| 137 | + expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN); |
| 138 | + expect(f.severity).toBe('error'); |
| 139 | + expect(f.path).toBe(`${TABS}[2].children[0].type`); |
| 140 | + expect(f.message).toContain(`\`${typo}\``); |
| 141 | + expect(f.message).toContain(`'${declared}'`); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe('a prop on either node is a `component-props-unknown-key` finding, naming the key', () => { |
| 146 | + it.each([ |
| 147 | + ['record:attachments', 'maxFiles'], |
| 148 | + ['record:attachments', 'accept'], |
| 149 | + ['record:approvals', 'approval'], |
| 150 | + ['record:approvals', 'showRemind'], |
| 151 | + ])('`%s` › `%s`', (type, key) => { |
| 152 | + const page = seededRecordPage([extraTab({ type, properties: { [key]: true } })]); |
| 153 | + const findings = validateComponentProps({ pages: [page] }); |
| 154 | + expect(findings).toHaveLength(1); |
| 155 | + const [f] = findings; |
| 156 | + expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); |
| 157 | + expect(f.severity).toBe('warning'); |
| 158 | + expect(f.path).toBe(`${TABS}[2].children[0].properties.${key}`); |
| 159 | + expect(f.where).toBe(`page "invoice_record" · ${type}`); |
| 160 | + expect(f.message).toContain(`\`${key}\``); |
| 161 | + expect(f.message).toContain(`\`${type}\``); |
| 162 | + |
| 163 | + // The same finding reaches the shared pipeline the authoring commands run. |
| 164 | + expect(validateVerdict(page).componentFindings.map((x) => x.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]); |
| 165 | + }); |
| 166 | +}); |
| 167 | + |
| 168 | +describe("`record:approvals` names the host's runtime channel when it is authored", () => { |
| 169 | + it.each([ |
| 170 | + ['approvals', { available: true, requests: [], pendingRequest: null }, "HOST's data channel"], |
| 171 | + ['currentUserId', 'usr_1', 'signed-in user'], |
| 172 | + ])('`%s`', (key, value, prescription) => { |
| 173 | + const page = seededRecordPage([approvalsTab({ type: 'record:approvals', properties: { [key]: value } })]); |
| 174 | + const findings = validateComponentProps({ pages: [page] }); |
| 175 | + expect(findings).toHaveLength(1); |
| 176 | + const [f] = findings; |
| 177 | + expect(f.rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); |
| 178 | + expect(f.path).toBe(`${TABS}[2].children[0].properties.${key}`); |
| 179 | + expect(`${f.message}\n${f.hint}`).toContain(prescription); |
| 180 | + }); |
| 181 | +}); |
0 commit comments