Skip to content

Commit 586770c

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(app-shell): view-ref widget — pick sourceView from a dropdown (ADR-0047) (#1721)
Adds a `view-ref` metadata-form widget so interfaceConfig.sourceView renders as a dropdown of the source object's views instead of a free-text name the author could mistype. Views come from a new WidgetContext.objectViews, loaded by ResourceEditPage for the page's source object. Out-of-catalog values survive; "None" omits the field (= object default). Mirrors the field-ref picker. Pairs with @objectstack/spec setting widget:'view-ref' + dependsOn:'source' on the page form's sourceView field. 4 unit tests; tsc clean. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b8a5d41 commit 586770c

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
metadata editor: `view-ref` widget for picking a source view
6+
7+
Adds a `view-ref` form widget so `interfaceConfig.sourceView` (and any field with `widget: 'view-ref'`) renders as a dropdown of the source object's views instead of a free-text name the author could mistype. Views come from a new `WidgetContext.objectViews`, which `ResourceEditPage` loads for the page's source object (`interfaceConfig.source` / `object`). A value not in the catalog is still shown so stale/custom names survive; clearing to "None" omits the field (the protocol treats absence as the object's default view). The widget mirrors the existing `field-ref` picker and degrades gracefully when no source object is bound.
8+
9+
Pairs with the `@objectstack/spec` change that sets `widget: 'view-ref'` + `dependsOn: 'source'` on the page form's `sourceView` field.

packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -501,9 +501,42 @@ function MetadataResourceEditPageImpl({
501501
return () => { cancelled = true; };
502502
}, [client, sourceObjectName]);
503503

504+
// View catalog of the source object — fuels the `view-ref` picker for
505+
// `interfaceConfig.sourceView` so the author chooses an existing view
506+
// instead of typing (and mistyping) a name. Views are standalone metadata
507+
// keyed to their object via `objectName`/`object`; the LIST endpoint returns
508+
// name + label, which is all the picker needs.
509+
const [objectViews, setObjectViews] = React.useState<Array<{ name: string; label?: string }>>([]);
510+
const [objectViewsLoading, setObjectViewsLoading] = React.useState(false);
511+
React.useEffect(() => {
512+
let cancelled = false;
513+
if (!sourceObjectName) { setObjectViews([]); return; }
514+
setObjectViewsLoading(true);
515+
(async () => {
516+
try {
517+
const all = (await client.list('view')) as Array<Record<string, any>>;
518+
if (cancelled) return;
519+
const forObject = (all || []).filter((v) => {
520+
const obj = v?.objectName ?? v?.object ?? v?.object_name;
521+
return obj === sourceObjectName;
522+
});
523+
const seen = new Set<string>();
524+
const list = forObject
525+
.map((v) => ({ name: v?.name as string, label: (v?.label as string) || undefined }))
526+
.filter((v) => !!v.name && !seen.has(v.name) && seen.add(v.name));
527+
setObjectViews(list);
528+
} catch {
529+
if (!cancelled) setObjectViews([]);
530+
} finally {
531+
if (!cancelled) setObjectViewsLoading(false);
532+
}
533+
})();
534+
return () => { cancelled = true; };
535+
}, [client, sourceObjectName]);
536+
504537
const widgetContext = React.useMemo(
505-
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading }),
506-
[objectNames, objectsLoading, objectFields, objectFieldsLoading],
538+
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading, objectViews, objectViewsLoading }),
539+
[objectNames, objectsLoading, objectFields, objectFieldsLoading, objectViews, objectViewsLoading],
507540
);
508541

509542
// Load layered view + initial draft.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterEach } from 'vitest';
4+
import { render, screen, cleanup } from '@testing-library/react';
5+
import { WIDGETS } from './widgets';
6+
7+
afterEach(cleanup);
8+
9+
const ViewRef = WIDGETS['view-ref'];
10+
11+
/**
12+
* ADR-0047 `view-ref` widget — picks `interfaceConfig.sourceView` from the
13+
* source object's views (context.objectViews) instead of a free-text name the
14+
* author could mistype. Radix Select portals its option list on open (flaky in
15+
* jsdom), so these tests cover the parts that render eagerly: the registry
16+
* wiring, the closed trigger, and graceful degradation with no views.
17+
*/
18+
describe('view-ref widget', () => {
19+
it('is registered in the WIDGETS map', () => {
20+
expect(ViewRef).toBeTypeOf('function');
21+
});
22+
23+
it('renders a combobox trigger when views are available', () => {
24+
render(
25+
<ViewRef
26+
value="default"
27+
onChange={() => {}}
28+
schema={{ type: 'string' }}
29+
context={{ objectViews: [
30+
{ name: 'default', label: 'All records' },
31+
{ name: 'mine', label: 'My records' },
32+
] }}
33+
/>,
34+
);
35+
expect(screen.getByRole('combobox')).toBeInTheDocument();
36+
});
37+
38+
it('renders (does not crash) when no source object / views are bound', () => {
39+
render(
40+
<ViewRef
41+
value={undefined}
42+
onChange={() => {}}
43+
schema={{ type: 'string' }}
44+
context={{ objectViews: [] }}
45+
/>,
46+
);
47+
expect(screen.getByRole('combobox')).toBeInTheDocument();
48+
});
49+
50+
it('renders with an out-of-catalog value without throwing (stale value survives)', () => {
51+
render(
52+
<ViewRef
53+
value="renamed_view"
54+
onChange={() => {}}
55+
schema={{ type: 'string' }}
56+
context={{ objectViews: [{ name: 'default', label: 'All records' }] }}
57+
/>,
58+
);
59+
expect(screen.getByRole('combobox')).toBeInTheDocument();
60+
});
61+
});

packages/app-shell/src/views/metadata-admin/widgets.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export interface WidgetContext {
4848
objectFields?: Array<{ name: string; label?: string; type?: string }>;
4949
/** Loading flag for the field catalog. */
5050
objectFieldsLoading?: boolean;
51+
/**
52+
* View catalog of the bound/source object. Drives the `view-ref` picker
53+
* so `interfaceConfig.sourceView` renders as a dropdown of the source
54+
* object's real views instead of a free-text name the author can typo.
55+
*/
56+
objectViews?: Array<{ name: string; label?: string }>;
57+
/** Loading flag for the view catalog. */
58+
objectViewsLoading?: boolean;
5159
}
5260

5361
export interface WidgetProps {
@@ -803,6 +811,51 @@ function FieldRefWidget({ id, value, onChange, readOnly, context }: WidgetProps)
803811
);
804812
}
805813

814+
/**
815+
* Single view picker for `interfaceConfig.sourceView`. Views come from
816+
* `context.objectViews` (the source object's views, loaded from the object
817+
* named by the sibling `source` field). A value not present in the catalog is
818+
* still shown so stale/custom names survive; clearing to "None" omits the
819+
* field, which the protocol treats as the object's default view. Replaces the
820+
* free-text input where an author could type a non-existent view name.
821+
*/
822+
function ViewRefWidget({ id, value, onChange, readOnly, context }: WidgetProps) {
823+
const locale = detectLocale();
824+
const views = context?.objectViews ?? [];
825+
const current = value == null ? '' : String(value);
826+
const inCatalog = !current || views.some((v) => v.name === current);
827+
return (
828+
<Select
829+
value={current || NO_FIELD}
830+
onValueChange={(v) => onChange(v === NO_FIELD ? undefined : v)}
831+
disabled={readOnly}
832+
>
833+
<SelectTrigger id={id}>
834+
<SelectValue placeholder={views.length ? t('engine.form.selectEllipsis', locale) : t('engine.form.noObjectBound', locale)} />
835+
</SelectTrigger>
836+
<SelectContent>
837+
<SelectItem value={NO_FIELD}>
838+
<span className="text-muted-foreground">{t('engine.form.none', locale)}</span>
839+
</SelectItem>
840+
{!inCatalog && current && (
841+
<SelectItem value={current}>
842+
<span className="font-mono">{current}</span>
843+
<span className="ml-2 text-xs text-muted-foreground">{t('engine.form.notInObject', locale)}</span>
844+
</SelectItem>
845+
)}
846+
{views.map((v) => (
847+
<SelectItem key={v.name} value={v.name}>
848+
<span className="flex items-center gap-2">
849+
<span>{v.label || v.name}</span>
850+
<code className="text-xs text-muted-foreground">{v.name}</code>
851+
</span>
852+
</SelectItem>
853+
))}
854+
</SelectContent>
855+
</Select>
856+
);
857+
}
858+
806859
/**
807860
* Ordered multi object-field picker. Used for props that reference a list of
808861
* fields (kanban card `columns`, gallery `visibleFields`, chart
@@ -1073,6 +1126,7 @@ export const WIDGETS: Record<string, WidgetRenderer> = {
10731126
'field-selector': FieldSelectorWidget,
10741127
'field-ref': FieldRefWidget,
10751128
'field-multi': FieldRefMultiWidget,
1129+
'view-ref': ViewRefWidget,
10761130
'master-detail': MasterDetailWidget,
10771131
'string-tags': StringTagsWidget,
10781132
'multiselect': MultiSelectWidget,

0 commit comments

Comments
 (0)