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
20 changes: 20 additions & 0 deletions .changeset/page-var-source-component-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@object-ui/app-shell": minor
---

feat(metadata-admin): page variable `source` is a component picker, not free text (#2328)

When editing a Page in Studio, a variable's **`source`** under Data Context now
renders as a dropdown of the component `id`s placed on the page, instead of a
plain text input the author had to type an id into by hand. This mirrors the
sibling `object` field's `ref:object` picker.

- New `ref:component` widget in `widgets.tsx` + a `collectPageComponentIds()`
helper that walks the draft's `regions[].components[]` tree (including nested
containers), de-duped in document order. Falls back to a free-text input when
the page has no components yet, and preserves stale/renamed ids.
- `WidgetContext` gains `componentIds`; `ResourceEditPage` derives it from the
live page draft so newly-placed components appear immediately.

Pairs with the framework form-spec change (`@objectstack/spec`) that pins
`widget: 'ref:component'` on the page `variables.source` sub-field.
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

afterEach(cleanup);

const RefComponent = WIDGETS['ref:component'];

/**
* #2328 — `ref:component` widget. A page variable's `source` names the component
* (by `id`) that writes it; this picks that id from the page's real canvas
* components (context.componentIds) instead of a free-text input the author can
* mistype. Radix Select portals its option list on open (flaky in jsdom), so
* the render tests cover the eagerly-rendered parts: registry wiring, the closed
* trigger, and graceful degradation with no components. The extraction logic is
* exercised directly via collectPageComponentIds.
*/
describe('ref:component widget', () => {
it('is registered in the WIDGETS map', () => {
expect(RefComponent).toBeTypeOf('function');
});

it('renders a combobox trigger when components are available', () => {
render(
<RefComponent
value="project_picker"
onChange={() => {}}
schema={{ type: 'string' }}
context={{ componentIds: [
{ id: 'project_picker', type: 'element:record_picker' },
{ id: 'task_list', type: 'record:related_list' },
] }}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});

it('degrades to a free-text input when the page has no components yet', () => {
render(
<RefComponent
value="typed_id"
onChange={() => {}}
schema={{ type: 'string' }}
context={{ componentIds: [] }}
/>,
);
// No combobox — a plain input carrying the stored value so it stays editable.
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('typed_id')).toBeInTheDocument();
});

it('renders with an out-of-tree value without throwing (stale/renamed id survives)', () => {
render(
<RefComponent
value="renamed_picker"
onChange={() => {}}
schema={{ type: 'string' }}
context={{ componentIds: [{ id: 'project_picker', type: 'element:record_picker' }] }}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});

/**
* collectPageComponentIds walks the page draft's region/component tree and
* returns every component that carries an `id`, de-duplicated in document order.
*/
describe('collectPageComponentIds', () => {
it('collects ids across regions in document order', () => {
const draft = {
regions: [
{ name: 'header', components: [{ type: 'nav:menu', id: 'main_nav' }] },
{
name: 'main',
components: [
{ type: 'element:record_picker', id: 'project_picker', label: 'Project' },
{ type: 'record:related_list', id: 'task_list' },
],
},
],
};
const ids = collectPageComponentIds(draft);
expect(ids.map((c) => c.id)).toEqual(['main_nav', 'project_picker', 'task_list']);
expect(ids[1]).toEqual({ id: 'project_picker', type: 'element:record_picker', label: 'Project' });
});

it('recurses into nested container components (components / children / properties)', () => {
const draft = {
regions: [
{
name: 'main',
components: [
{
type: 'page:tabs',
id: 'outer_tabs',
components: [{ type: 'element:record_picker', id: 'nested_picker' }],
},
{
type: 'page:section',
id: 'outer_section',
properties: { children: [{ type: 'element:button', id: 'deep_button' }] },
},
],
},
],
};
const ids = collectPageComponentIds(draft).map((c) => c.id);
expect(ids).toEqual(['outer_tabs', 'nested_picker', 'outer_section', 'deep_button']);
});

it('skips components without an id and de-duplicates repeated ids (first wins)', () => {
const draft = {
regions: [
{
name: 'main',
components: [
{ type: 'element:divider' }, // no id → skipped
{ type: 'element:record_picker', id: 'dup', label: 'First' },
{ type: 'element:record_picker', id: 'dup', label: 'Second' },
],
},
],
};
const ids = collectPageComponentIds(draft);
expect(ids).toHaveLength(1);
expect(ids[0]).toEqual({ id: 'dup', type: 'element:record_picker', label: 'First' });
});

it('returns an empty list for a page with no regions', () => {
expect(collectPageComponentIds({})).toEqual([]);
expect(collectPageComponentIds(undefined)).toEqual([]);
expect(collectPageComponentIds({ regions: [] })).toEqual([]);
});
});

/**
* Integration seam (#2328): a page's `variables` repeater sub-field `source`,
* declared with `widget: 'ref:component'` in the framework form spec, must route
* through SchemaForm's card-layout repeater → FieldRow → FieldControl → the
* `ref:component` renderer, fed by `widgetContext.componentIds`. This proves the
* whole path (not just the widget in isolation) turns `source` into a dropdown.
*/
describe('SchemaForm → variables.source integration', () => {
const schema = {
type: 'object',
properties: {
variables: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
source: { type: 'string' },
},
},
},
},
};
const form = {
type: 'simple' as const,
sections: [
{
label: 'Data Context',
fields: [
{
field: 'variables',
type: 'repeater',
fields: [
{ field: 'name' },
{ field: 'source', widget: 'ref:component' },
],
},
],
},
],
};

it('renders the source sub-field as a component picker (combobox), not a text input', () => {
render(
<SchemaForm
schema={schema}
form={form}
value={{ variables: [{ name: 'selectedProjectId', source: 'project_picker' }] }}
onChange={() => {}}
widgetContext={{ componentIds: [
{ id: 'project_picker', type: 'element:record_picker' },
{ id: 'task_list', type: 'record:related_list' },
] }}
/>,
);
// The card-layout repeater row is collapsed by default; expand it to reveal
// the sub-fields, then assert `source` rendered the component-picker combobox.
fireEvent.click(screen.getByRole('button', { name: /#1/ }));
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
15 changes: 13 additions & 2 deletions packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { MetadataTypeActions } from './MetadataTypeActions';
import { LayeredDiff, countOverlaidFields } from './LayeredDiff';
import { DraftReviewPanel, computeDraftChangeCount } from './DraftReviewPanel';
import { SchemaForm, type SchemaFormIssue } from './SchemaForm';
import { collectPageComponentIds } from './widgets';
import {
useMetadataClient,
useMetadataTypes,
Expand Down Expand Up @@ -652,9 +653,19 @@ function MetadataResourceEditPageImpl({
return () => { cancelled = true; };
}, [client, sourceObjectName]);

// Component ids placed on the page being edited — fuels the `ref:component`
// picker so a page variable's `source` (the component that writes it) is
// chosen from the real canvas components, not a free-text id. Derived from
// the live draft so newly-added components appear immediately; a no-op empty
// list for non-page types (they carry no `regions`).
const componentIds = React.useMemo(
() => (type === 'page' ? collectPageComponentIds(draft) : []),
[type, draft],
);

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

// Load layered view + initial draft.
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.form.loadingObjects': 'Loading objects…',
'engine.form.noObjects': 'object_name (no objects detected)',
'engine.form.selectObject': 'Select object…',
'engine.form.selectComponent': 'Select component…',
'engine.form.noComponents': 'component_id (no components on this page yet)',
'engine.form.selectObjectDots': 'Select object...',
'engine.form.addObjects': 'Add objects...',
'engine.form.loadingFields': 'Loading fields…',
Expand Down Expand Up @@ -1819,6 +1821,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.form.loadingObjects': '正在加载对象…',
'engine.form.noObjects': 'object_name(未检测到对象)',
'engine.form.selectObject': '选择对象…',
'engine.form.selectComponent': '选择组件…',
'engine.form.noComponents': 'component_id(此页面暂无组件)',
'engine.form.selectObjectDots': '选择对象...',
'engine.form.addObjects': '添加对象...',
'engine.form.loadingFields': '正在加载字段…',
Expand Down
Loading
Loading