Skip to content

Commit 7cda1ad

Browse files
os-zhuangclaude
andauthored
feat: render element:metadata_viewer + ```metadata doc fence (ADR-0051 P1) (#1774)
* feat(components,markdown): render `element:metadata_viewer` + ```metadata doc fence (ADR-0051 P1) The objectui half of ADR-0051 "inline metadata views in docs". A ```metadata fenced block in a doc is lifted into a live, read-only `element:metadata_viewer` SDUI component — the same component a page node renders, so docs and pages share one runtime. - components: new `element:metadata_viewer` renderer (Content element, read-only). Resolves the target live via useMetadataItem and renders read-only projections of: · state_machine — transition graph from a `state_machine` validation rule (initial/final badges, field-option colors) · flow — ordered step list; `detail: business` folds technical nodes · permission — object-level C/R/U/D matrix Registered in ComponentRegistry; degrades to a clear placeholder on a missing/forbidden reference. - plugin-markdown: dispatch ```metadata at the <pre> level (beside ```mermaid) into the SDUI component via the shared ComponentRegistry; flat `key: value` body parser (data, not code); `metadata` added to rehype-highlight plainText so the body is read verbatim. Verified in browser against the app-showcase backend: task/project state machines, a flow (technical steps folded), and a permission matrix all render live with no console errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(adr-0051): cover element:metadata_viewer + the ```metadata fence parser - metadata-viewer.test.tsx: renders the component via ComponentRegistry with a mocked metadata context — asserts state_machine (option labels, initial/final badges), flow (business-altitude folding of technical nodes + the hidden-count note, and detail:technical revealing them), permission (CRUD matrix), missing-rule + unknown-type degradation. - metadata-fence.test.ts: pins the flat `key: value` body contract (quote stripping, comments, colon-in-value, malformed-line skipping). Exports parseMetadataFence for the unit test. 13 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 712cbd0 commit 7cda1ad

5 files changed

Lines changed: 619 additions & 3 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* element:metadata_viewer (ADR-0051) — read-only live metadata views.
3+
*
4+
* Renders the component through ComponentRegistry (the same path a
5+
* ```metadata doc fence and a page node both use) with a mocked metadata
6+
* context, and asserts each of the three view kinds plus graceful
7+
* degradation.
8+
*/
9+
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { render, screen, waitFor } from '@testing-library/react';
12+
import { ComponentRegistry } from '@object-ui/core';
13+
import { MetadataCtx } from '@object-ui/react';
14+
import '../renderers/basic/metadata-viewer';
15+
16+
function metaCtx(getItem: (type: string, name: string) => Promise<any>) {
17+
return {
18+
apps: [], objects: [], dashboards: [], reports: [], pages: [],
19+
loading: false, error: null,
20+
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
21+
getItem, getItemsByType: () => [], getTypeStatus: () => 'ready' as const,
22+
};
23+
}
24+
25+
function Viewer({ properties, getItem }: { properties: any; getItem: (t: string, n: string) => Promise<any> }) {
26+
const C = ComponentRegistry.get('element:metadata_viewer');
27+
if (!C) throw new Error('element:metadata_viewer not registered');
28+
return (
29+
<MetadataCtx.Provider value={metaCtx(getItem) as any}>
30+
<C schema={{ type: 'element:metadata_viewer', properties }} />
31+
</MetadataCtx.Provider>
32+
);
33+
}
34+
35+
const TASK_OBJECT = {
36+
name: 'showcase_task',
37+
fields: {
38+
status: {
39+
type: 'select',
40+
options: [
41+
{ value: 'backlog', label: 'Backlog', color: '#94A3B8', default: true },
42+
{ value: 'todo', label: 'To Do', color: '#3B82F6' },
43+
{ value: 'in_progress', label: 'In Progress', color: '#F59E0B' },
44+
{ value: 'done', label: 'Done', color: '#10B981' },
45+
],
46+
},
47+
},
48+
validations: [
49+
{
50+
type: 'state_machine',
51+
name: 'task_status_flow',
52+
label: 'Task Status Flow',
53+
field: 'status',
54+
transitions: {
55+
backlog: ['todo'],
56+
todo: ['in_progress', 'backlog'],
57+
in_progress: ['done'],
58+
done: [],
59+
},
60+
},
61+
],
62+
};
63+
64+
describe('element:metadata_viewer', () => {
65+
it('registers as a component', () => {
66+
expect(ComponentRegistry.get('element:metadata_viewer')).toBeTruthy();
67+
});
68+
69+
describe('state_machine', () => {
70+
it('renders the transition graph with option labels, initial + final badges', async () => {
71+
const getItem = vi.fn(async (type: string) => (type === 'object' ? TASK_OBJECT : null));
72+
render(<Viewer getItem={getItem} properties={{ type: 'state_machine', object: 'showcase_task', name: 'task_status_flow' }} />);
73+
74+
await waitFor(() => expect(screen.getByText('Task Status Flow')).toBeTruthy());
75+
// option label, not the raw value
76+
expect(screen.getAllByText('Backlog').length).toBeGreaterThan(0);
77+
expect(screen.getAllByText('In Progress').length).toBeGreaterThan(0);
78+
// backlog is the default option → initial; done has no outgoing → final
79+
expect(screen.getByText(/initial/i)).toBeTruthy();
80+
expect(screen.getByText(/final/i)).toBeTruthy();
81+
expect(getItem).toHaveBeenCalledWith('object', 'showcase_task');
82+
});
83+
84+
it('warns when the named rule is absent', async () => {
85+
const getItem = vi.fn(async () => ({ name: 'x', validations: [] }));
86+
render(<Viewer getItem={getItem} properties={{ type: 'state_machine', object: 'x', name: 'nope' }} />);
87+
await waitFor(() => expect(screen.getByText(/No state machine/i)).toBeTruthy());
88+
});
89+
});
90+
91+
describe('flow', () => {
92+
const FLOW = {
93+
name: 'reassign',
94+
label: 'Reassign Task',
95+
nodes: [
96+
{ id: 'start', type: 'start', label: 'Start' },
97+
{ id: 's1', type: 'script', label: 'Do Plumbing' },
98+
{ id: 'end', type: 'end', label: 'End' },
99+
],
100+
};
101+
102+
it('folds technical nodes at business altitude (default)', async () => {
103+
const getItem = vi.fn(async () => FLOW);
104+
render(<Viewer getItem={getItem} properties={{ type: 'flow', name: 'reassign' }} />);
105+
await waitFor(() => expect(screen.getByText('Reassign Task')).toBeTruthy());
106+
expect(screen.getByText('Start')).toBeTruthy();
107+
expect(screen.getByText('End')).toBeTruthy();
108+
expect(screen.queryByText('Do Plumbing')).toBeNull(); // script folded
109+
expect(screen.getByText(/technical step.*hidden/i)).toBeTruthy();
110+
});
111+
112+
it('shows technical nodes when detail=technical', async () => {
113+
const getItem = vi.fn(async () => FLOW);
114+
render(<Viewer getItem={getItem} properties={{ type: 'flow', name: 'reassign', detail: 'technical' }} />);
115+
await waitFor(() => expect(screen.getByText('Do Plumbing')).toBeTruthy());
116+
});
117+
});
118+
119+
describe('permission', () => {
120+
it('renders an object CRUD matrix', async () => {
121+
const getItem = vi.fn(async () => ({
122+
name: 'contributor',
123+
label: 'Contributor',
124+
objects: { showcase_task: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false } },
125+
}));
126+
render(<Viewer getItem={getItem} properties={{ type: 'permission', name: 'contributor' }} />);
127+
await waitFor(() => expect(screen.getByText('Contributor')).toBeTruthy());
128+
expect(screen.getByText('showcase_task')).toBeTruthy();
129+
});
130+
});
131+
132+
it('degrades on an unknown view type', () => {
133+
render(<Viewer getItem={async () => null} properties={{ type: 'nonsense' }} />);
134+
expect(screen.getByText(/Unknown metadata view type/i)).toBeTruthy();
135+
});
136+
});

packages/components/src/renderers/basic/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ import './button-group';
1717
import './pagination';
1818
import './navigation-menu';
1919
import './elements';
20+
import './metadata-viewer';
2021
import './data-list';

0 commit comments

Comments
 (0)