diff --git a/.changeset/page-var-source-component-picker.md b/.changeset/page-var-source-component-picker.md new file mode 100644 index 000000000..3ff5ca407 --- /dev/null +++ b/.changeset/page-var-source-component-picker.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/RefComponentWidget.test.tsx b/packages/app-shell/src/views/metadata-admin/RefComponentWidget.test.tsx new file mode 100644 index 000000000..44a287592 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/RefComponentWidget.test.tsx @@ -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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index e10c16dee..892fe5154 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -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, @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 9ec2c63e7..efbaf0f2b 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -552,6 +552,8 @@ const ENGINE_STRINGS_EN: Record = { '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…', @@ -1819,6 +1821,8 @@ const ENGINE_STRINGS_ZH: Record = { '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': '正在加载字段…', diff --git a/packages/app-shell/src/views/metadata-admin/widgets.tsx b/packages/app-shell/src/views/metadata-admin/widgets.tsx index ad9566a7c..08209ecdc 100644 --- a/packages/app-shell/src/views/metadata-admin/widgets.tsx +++ b/packages/app-shell/src/views/metadata-admin/widgets.tsx @@ -85,6 +85,14 @@ export interface WidgetContext { * fields depending on a sibling selection (driver → connection settings). */ dynamicSchemas?: Record; required?: string[] }>; + /** + * Component ids placed on the page being edited — extracted from the draft's + * `regions[].components[]` tree (see {@link collectPageComponentIds}). Drives + * the `ref:component` picker so a page variable's `source` (the component that + * writes it) is chosen from the real components on the canvas instead of a + * free-text id the author can typo. + */ + componentIds?: Array<{ id: string; type?: string; label?: string }>; } export interface WidgetProps { @@ -183,6 +191,125 @@ function RefObjectWidget({ ); } +/* -------------------------------------------------------------------------- */ +/* ref:component — pick a component id from the page's canvas tree */ +/* -------------------------------------------------------------------------- */ + +/** + * Walk a page draft's `regions[].components[]` tree and collect every component + * that carries an `id`, returning `{ id, type, label }` in document order (and + * de-duplicated on id, first-wins). Traverses nested containers via any array- + * valued `components` / `children` under a node or its `properties`, so ids + * inside `page:tabs` / `page:section` style containers are surfaced too. + * + * Used to fuel the `ref:component` picker (a page variable's `source` names the + * component that writes it). Exported for unit testing. + */ +export function collectPageComponentIds( + draft: unknown, +): Array<{ id: string; type?: string; label?: string }> { + const out: Array<{ id: string; type?: string; label?: string }> = []; + const seen = new Set(); + const visitNode = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + const rec = node as Record; + const id = rec.id; + if (typeof id === 'string' && id && !seen.has(id)) { + seen.add(id); + out.push({ + id, + type: typeof rec.type === 'string' ? rec.type : undefined, + label: typeof rec.label === 'string' ? rec.label : undefined, + }); + } + // Recurse into nested component collections — directly on the node and + // under `properties` (some container components nest children there). + visitList(rec.components); + visitList(rec.children); + const props = rec.properties; + if (props && typeof props === 'object') { + visitList((props as Record).components); + visitList((props as Record).children); + } + }; + const visitList = (list: unknown): void => { + if (Array.isArray(list)) for (const n of list) visitNode(n); + }; + if (draft && typeof draft === 'object') { + const regions = (draft as Record).regions; + if (Array.isArray(regions)) { + for (const region of regions) { + if (region && typeof region === 'object') { + visitList((region as Record).components); + } + } + } + } + return out; +} + +/** + * Single component-id picker for a page variable's `source` — the component + * whose selection writes the variable (e.g. an `element:record_picker` with + * `id="project_picker"`). Component ids come from `context.componentIds` + * (extracted from the page draft's region/component tree). A stored value not + * present in the tree is still shown so a stale/renamed id survives; when the + * page has no components yet, degrades to a free-text input so the field stays + * editable. Mirrors {@link RefObjectWidget} / {@link ViewRefWidget}. + */ +function RefComponentWidget({ id, value, onChange, readOnly, context }: WidgetProps) { + const locale = detectLocale(); + const components = context?.componentIds ?? []; + const current = value == null ? '' : String(value); + // No components placed yet → free-text fallback so the field is still usable. + if (components.length === 0) { + return ( + onChange(e.target.value || undefined)} + placeholder={t('engine.form.noComponents', locale)} + /> + ); + } + const inTree = !current || components.some((c) => c.id === current); + return ( + + ); +} + /* -------------------------------------------------------------------------- */ /* object-selector — multi-select object picker */ /* -------------------------------------------------------------------------- */ @@ -564,6 +691,18 @@ function RowCell({ /> ); } + // ref:component hint inside a row cell (page variable `source` picker) + if (schema?.widget === 'ref:component') { + return ( + + ); + } // enum → dropdown const enumVals = schema?.enum as unknown[] | undefined; if (Array.isArray(enumVals) && enumVals.length > 0) { @@ -1806,6 +1945,7 @@ function DynamicConfigWidget({ value, onChange, readOnly, fieldSpec, formData, c export const WIDGETS: Record = { 'ref:object': RefObjectWidget, + 'ref:component': RefComponentWidget, 'filter-mode': FilterModeWidget, 'object-selector': ObjectSelectorWidget, 'field-selector': FieldSelectorWidget,