Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/23746-approval-request-raw-json.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, AnyRec[]> = {
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<AnyRec> {
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();
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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([]);
Expand All @@ -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([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' },
Expand Down
Loading