Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/metadata-view-ref-widget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@object-ui/app-shell": minor
---

metadata editor: `view-ref` widget for picking a source view

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.

Pairs with the `@objectstack/spec` change that sets `widget: 'view-ref'` + `dependsOn: 'source'` on the page form's `sourceView` field.
37 changes: 35 additions & 2 deletions packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,42 @@ function MetadataResourceEditPageImpl({
return () => { cancelled = true; };
}, [client, sourceObjectName]);

// View catalog of the source object — fuels the `view-ref` picker for
// `interfaceConfig.sourceView` so the author chooses an existing view
// instead of typing (and mistyping) a name. Views are standalone metadata
// keyed to their object via `objectName`/`object`; the LIST endpoint returns
// name + label, which is all the picker needs.
const [objectViews, setObjectViews] = React.useState<Array<{ name: string; label?: string }>>([]);
const [objectViewsLoading, setObjectViewsLoading] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
if (!sourceObjectName) { setObjectViews([]); return; }
setObjectViewsLoading(true);
(async () => {
try {
const all = (await client.list('view')) as Array<Record<string, any>>;
if (cancelled) return;
const forObject = (all || []).filter((v) => {
const obj = v?.objectName ?? v?.object ?? v?.object_name;
return obj === sourceObjectName;
});
const seen = new Set<string>();
const list = forObject
.map((v) => ({ name: v?.name as string, label: (v?.label as string) || undefined }))
.filter((v) => !!v.name && !seen.has(v.name) && seen.add(v.name));
setObjectViews(list);
} catch {
if (!cancelled) setObjectViews([]);
} finally {
if (!cancelled) setObjectViewsLoading(false);
}
})();
return () => { cancelled = true; };
}, [client, sourceObjectName]);

const widgetContext = React.useMemo(
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading }),
[objectNames, objectsLoading, objectFields, objectFieldsLoading],
() => ({ objectNames, objectsLoading, objectFields, objectFieldsLoading, objectViews, objectViewsLoading }),
[objectNames, objectsLoading, objectFields, objectFieldsLoading, objectViews, objectViewsLoading],
);

// Load layered view + initial draft.
Expand Down
61 changes: 61 additions & 0 deletions packages/app-shell/src/views/metadata-admin/ViewRefWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { WIDGETS } from './widgets';

afterEach(cleanup);

const ViewRef = WIDGETS['view-ref'];

/**
* ADR-0047 `view-ref` widget — picks `interfaceConfig.sourceView` from the
* source object's views (context.objectViews) instead of a free-text name the
* author could mistype. Radix Select portals its option list on open (flaky in
* jsdom), so these tests cover the parts that render eagerly: the registry
* wiring, the closed trigger, and graceful degradation with no views.
*/
describe('view-ref widget', () => {
it('is registered in the WIDGETS map', () => {
expect(ViewRef).toBeTypeOf('function');
});

it('renders a combobox trigger when views are available', () => {
render(
<ViewRef
value="default"
onChange={() => {}}
schema={{ type: 'string' }}
context={{ objectViews: [
{ name: 'default', label: 'All records' },
{ name: 'mine', label: 'My records' },
] }}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});

it('renders (does not crash) when no source object / views are bound', () => {
render(
<ViewRef
value={undefined}
onChange={() => {}}
schema={{ type: 'string' }}
context={{ objectViews: [] }}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});

it('renders with an out-of-catalog value without throwing (stale value survives)', () => {
render(
<ViewRef
value="renamed_view"
onChange={() => {}}
schema={{ type: 'string' }}
context={{ objectViews: [{ name: 'default', label: 'All records' }] }}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
54 changes: 54 additions & 0 deletions packages/app-shell/src/views/metadata-admin/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export interface WidgetContext {
objectFields?: Array<{ name: string; label?: string; type?: string }>;
/** Loading flag for the field catalog. */
objectFieldsLoading?: boolean;
/**
* View catalog of the bound/source object. Drives the `view-ref` picker
* so `interfaceConfig.sourceView` renders as a dropdown of the source
* object's real views instead of a free-text name the author can typo.
*/
objectViews?: Array<{ name: string; label?: string }>;
/** Loading flag for the view catalog. */
objectViewsLoading?: boolean;
}

export interface WidgetProps {
Expand Down Expand Up @@ -803,6 +811,51 @@ function FieldRefWidget({ id, value, onChange, readOnly, context }: WidgetProps)
);
}

/**
* Single view picker for `interfaceConfig.sourceView`. Views come from
* `context.objectViews` (the source object's views, loaded from the object
* named by the sibling `source` field). A value not present in the catalog is
* still shown so stale/custom names survive; clearing to "None" omits the
* field, which the protocol treats as the object's default view. Replaces the
* free-text input where an author could type a non-existent view name.
*/
function ViewRefWidget({ id, value, onChange, readOnly, context }: WidgetProps) {
const locale = detectLocale();
const views = context?.objectViews ?? [];
const current = value == null ? '' : String(value);
const inCatalog = !current || views.some((v) => v.name === current);
return (
<Select
value={current || NO_FIELD}
onValueChange={(v) => onChange(v === NO_FIELD ? undefined : v)}
disabled={readOnly}
>
<SelectTrigger id={id}>
<SelectValue placeholder={views.length ? t('engine.form.selectEllipsis', locale) : t('engine.form.noObjectBound', locale)} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_FIELD}>
<span className="text-muted-foreground">{t('engine.form.none', locale)}</span>
</SelectItem>
{!inCatalog && current && (
<SelectItem value={current}>
<span className="font-mono">{current}</span>
<span className="ml-2 text-xs text-muted-foreground">{t('engine.form.notInObject', locale)}</span>
</SelectItem>
)}
{views.map((v) => (
<SelectItem key={v.name} value={v.name}>
<span className="flex items-center gap-2">
<span>{v.label || v.name}</span>
<code className="text-xs text-muted-foreground">{v.name}</code>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

/**
* Ordered multi object-field picker. Used for props that reference a list of
* fields (kanban card `columns`, gallery `visibleFields`, chart
Expand Down Expand Up @@ -1073,6 +1126,7 @@ export const WIDGETS: Record<string, WidgetRenderer> = {
'field-selector': FieldSelectorWidget,
'field-ref': FieldRefWidget,
'field-multi': FieldRefMultiWidget,
'view-ref': ViewRefWidget,
'master-detail': MasterDetailWidget,
'string-tags': StringTagsWidget,
'multiselect': MultiSelectWidget,
Expand Down
Loading