diff --git a/.changeset/23746-approval-request-raw-json.md b/.changeset/23746-approval-request-raw-json.md new file mode 100644 index 00000000000..503771cd525 --- /dev/null +++ b/.changeset/23746-approval-request-raw-json.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +feat(plugin-approvals): the approval request page's Details tab ends with the request snapshot's raw JSON, gated on `studio.access` + +Clause-②: no + +- **One block on the served `sys_approval_request_detail` page.** The Details tab now holds `record:details`, then a `record:json` block on `payload`, labelled "Raw data (JSON)" in the four shipped locales and gated on `requiredPermissions: ['studio.access']`. It is a platform operator's debug view, never the approver's read path: a viewer without `studio.access` draws nothing there. +- **`payload` is the key the request read serves.** `GET /api/v1/approvals/requests/:id`, the read the console's request page loads its record through, serves the parsed snapshot as `payload`, already narrowed to the fields the reader may see on the subject object. The stored column `payload_json` is never served under its own name, and the object definition is unchanged. +- **The block is drawn by the console's `record:json` renderer.** A console with no renderer registered for `record:json` draws its unknown-component panel in that place. +- **Nothing else moves.** No export, type, route, stored value or accepted input changes. + +A patch: one block on one platform page's served definition. diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts index 04afdb51cac..0393d9f86d7 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-page.test.ts @@ -20,13 +20,16 @@ * component-type vocabulary, the props rows and the build rules — with a * control for each half that can go red; * 4. its label is translated in every shipped locale, `en` byte-equal to - * the page literal, and served as the translation (not the fallback). + * the page literal, and served as the translation (not the fallback); + * 5. the Details tab's `record:json` panel names a key the request read + * really serves — no publish check judges that `field`, so this does. */ import { describe, it, expect } from 'vitest'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; import { PageSchema } from '@objectstack/spec/ui'; import { + COMPONENT_PROPS_INVALID, COMPONENT_PROPS_UNKNOWN_KEY, COMPONENT_TYPE_UNKNOWN, runAuthoringRules, @@ -35,6 +38,7 @@ import { validateComponentTypes, } from '@objectstack/lint'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { ApprovalService } from './approval-service.js'; import { SysApprovalRequestDetailPage } from './sys-approval-request.page.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; @@ -105,6 +109,77 @@ function pageBuildFindings(page: AnyRec) { /** The page with its decision node replaced. */ const withDecisionNode = (node: AnyRec): AnyRec => ({ ...PAGE, slots: { ...SLOTS, actions: [node] } }); +/** The page with the Details tab's raw snapshot panel given other properties. */ +const withRawPanelProps = (properties: AnyRec): AnyRec => { + const [details, ...rest] = TAB_ITEMS; + const children = [(details.children as AnyRec[])[0], { type: 'record:json', properties }]; + const items = [{ ...details, children }, ...rest]; + return { ...PAGE, slots: { ...SLOTS, tabs: { ...SLOTS.tabs, properties: { ...SLOTS.tabs.properties, items } } } }; +}; + +/** The subject-record snapshot a stored request row holds in `payload_json`. */ +const SNAPSHOT = { id: 'exp_1', title: 'Q1 travel', amount: 1200 }; +const SUBMITTER = 'employee_7'; + +/** + * The `where` shapes the request read sends: equality, `$in`, `$or`, `$and`. + * Any other operator or combinator is REFUSED, so a read this double cannot + * answer fails the test instead of matching nothing. + */ +function matchesWhere(row: AnyRec, where: AnyRec | undefined): boolean { + return Object.entries(where ?? {}).every(([k, v]) => { + if (k === '$or') return (v as AnyRec[]).some((w) => matchesWhere(row, w)); + if (k === '$and') return (v as AnyRec[]).every((w) => matchesWhere(row, w)); + if (k.startsWith('$')) throw new Error(`engine double: unsupported combinator ${k}`); + if (v != null && typeof v === 'object') { + const ops = Object.keys(v); + if (ops.length === 1 && ops[0] === '$in') return (v.$in as unknown[]).includes(row[k]); + throw new Error(`engine double: unsupported operator ${ops.join(', ')} on ${k}`); + } + return row[k] === v; + }); +} + +/** A find-only engine double over one stored request and its subject record. */ +function storedRequestEngine() { + const tables: Record = { + sys_approval_request: [{ + id: 'req_1', + organization_id: 't1', + process_name: 'flow:expense_review', + object_name: 'expense_report', + record_id: 'exp_1', + submitter_id: SUBMITTER, + status: 'pending', + current_step: 'finance', + pending_approvers: 'finance_user', + payload_json: JSON.stringify(SNAPSHOT), + created_at: '2026-08-01T00:00:00Z', + }], + expense_report: [{ ...SNAPSHOT, organization_id: 't1' }], + }; + return { + async find(object: string, options?: AnyRec) { + const rows = (tables[object] ?? []).filter((r) => matchesWhere(r, options?.where ?? options?.filter)); + const page = typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; + return page.map((r) => ({ ...r })); + }, + }; +} + +/** + * One request, read the way the request page loads its record. The console + * routes that `findOne` to `GET /approvals/requests/:id` (objectui's + * `routeApprovalRecordReads`), whose handler answers `ApprovalService.getRequest`'s + * row as it stands; this is that call, as the request's submitter. + */ +async function servedRequestRow(): Promise { + const row = await new ApprovalService({ engine: storedRequestEngine() as any }) + .getRequest('req_1', { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any); + expect(row, 'the submitter reads their own request').not.toBeNull(); + return row as AnyRec; +} + describe('sys_approval_request detail page — served through the manifest', () => { it('the plugin manifest carries the page in `pages`', async () => { const manifest = await registeredManifest(); @@ -150,9 +225,17 @@ describe('sys_approval_request detail page — the slot map', () => { expect((SysApprovalRequest.fields as AnyRec).record_id?.referenceVia).toBe('object_name'); }); - it('tabs: Details is record:details, Timeline is sys_approval_action as a related list on request_id', () => { + it('tabs: Details is record:details then the raw snapshot panel, Timeline is sys_approval_action as a related list on request_id', () => { expect(TAB_ITEMS.map((t) => t.label?.en)).toEqual(['Details', 'Timeline']); - expect(TAB_ITEMS[0].children).toEqual([{ type: 'record:details' }]); + const details = TAB_ITEMS[0].children as AnyRec[]; + expect(details.map((n) => n.type)).toEqual(['record:details', 'record:json']); + expect(details[0]).toEqual({ type: 'record:details' }); + // The panel: the parsed request snapshot, gated on the platform-scope + // capability the inbox side sheet's raw panel is gated on. Its label is + // copy, so only its locales are held here. + const { label, ...panel } = details[1].properties as AnyRec; + expect(panel).toEqual({ field: 'payload', requiredPermissions: ['studio.access'] }); + expect(Object.keys(label).sort()).toEqual(Object.keys(ApprovalsTranslations).sort()); const [timeline] = TAB_ITEMS[1].children as AnyRec[]; expect(timeline.type).toBe('record:related_list'); expect(timeline.properties).toMatchObject({ objectName: 'sys_approval_action', relationshipField: 'request_id' }); @@ -180,6 +263,23 @@ describe('sys_approval_request detail page — the slot map', () => { }); }); +describe('sys_approval_request detail page — the raw snapshot panel draws a key the request read serves', () => { + // `record:json`'s `field` is not judged against the object's fields at + // publish (the key it exists for is served beside them), so a key the + // request read does not serve draws nothing, and nothing says so. + it('the request read serves the parsed snapshot under the panel\'s `field`, and never the stored column', async () => { + const panels = nodesOf(SLOTS).filter((n) => n.type === 'record:json'); + expect(panels).toHaveLength(1); + const { field } = panels[0].properties as AnyRec; + const row = await servedRequestRow(); + expect({ field, served: row[field] }).toEqual({ field, served: SNAPSHOT }); + // `payload_json` is the declared column the stored row holds; the read + // serves its parse as `payload`, never the column under its own name. + expect(Object.keys(SysApprovalRequest.fields)).toContain('payload_json'); + expect(row).not.toHaveProperty('payload_json'); + }); +}); + describe('sys_approval_request detail page — what `os validate` judges a page by', () => { it('the component-type vocabulary knows every node, record:approval_decision included', () => { expect(validateComponentTypes({ pages: [PAGE] })).toEqual([]); @@ -199,6 +299,16 @@ describe('sys_approval_request detail page — what `os validate` judges a page expect(findings.map((f) => f.rule)).toEqual([COMPONENT_PROPS_UNKNOWN_KEY]); }); + it('control: the raw snapshot panel without its required gate is refused by the same call', () => { + // The page schema's parse does not judge a node's props bag; the props + // row does, so this call is what holds the panel's gate in place. + const { requiredPermissions: _gate, ...ungated } = (TAB_ITEMS[0].children as AnyRec[])[1].properties as AnyRec; + const findings = validateComponentProps({ pages: [withRawPanelProps(ungated)] }); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [COMPONENT_PROPS_INVALID, 'pages[0].slots.tabs.properties.items[0].children[1].properties.requiredPermissions'], + ]); + }); + it('the build rules raise no error on the page, in a stack beside the two objects it binds', () => { const { errors } = splitBySeverity(pageBuildFindings(PAGE)); expect(errors, JSON.stringify(errors, null, 2)).toEqual([]); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts index e3b2343e065..42f3675ea82 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.page.ts @@ -38,6 +38,11 @@ import type { Page } from '@objectstack/spec/ui'; * authored rather than `details` because the console builds the tab strip * from `tabs` when both are present; the details body therefore lives in * the first tab here, and the page authors no `details` slot. + * - The Details tab ends with the request snapshot's raw JSON: a + * `record:json` block on `payload`, the parsed snapshot the request read + * (`GET /approvals/requests/:id`) serves; the stored column `payload_json` + * is never served under its own name. It is a platform operator's debug + * view, gated on `studio.access`, and never the approver's read path. * - `discussion: []` removes the generic discussion thread. A request's * thread is its timeline, and its reply is the declared `approval_comment` * action, which the service stores as a `sys_approval_action` row the @@ -82,7 +87,17 @@ export const SysApprovalRequestDetailPage: Page = { { label: { en: 'Details', 'zh-CN': '详情', 'ja-JP': '詳細', 'es-ES': 'Detalles' }, icon: 'file-text', - children: [{ type: 'record:details' }], + children: [ + { type: 'record:details' }, + { + type: 'record:json', + properties: { + field: 'payload', + label: { en: 'Raw data (JSON)', 'zh-CN': '原始数据(JSON)', 'ja-JP': '生データ(JSON)', 'es-ES': 'Datos sin procesar (JSON)' }, + requiredPermissions: ['studio.access'], + }, + }, + ], }, { label: { en: 'Timeline', 'zh-CN': '时间线', 'ja-JP': 'タイムライン', 'es-ES': 'Cronología' },