diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index d020e21bb..b2c292132 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -2019,6 +2019,7 @@ function MetadataResourceEditPageImpl({ onSelectionChange={setSelection} readOnly={formReadOnly} locale={locale} + serverSchema={entry?.schema as Record | undefined} /> ) : ( ; } export type MetadataDefaultInspector = ComponentType; diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 4979fd6b9..93a9b617d 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -416,6 +416,8 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.report.columns': 'Columns', 'engine.inspector.report.columnsEmpty': 'No columns yet. Add a field below.', 'engine.inspector.report.noSchema': 'Spec schema unavailable — basic properties only.', + // Trailing section for fields the live server has but the bundled spec lacks. + 'engine.inspector.moreFields': 'More fields', // Dashboard default (home) inspector 'engine.inspector.dashboard.kind': 'Dashboard', 'engine.inspector.dashboard.close': 'Close dashboard', @@ -1050,6 +1052,8 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.report.columns': '列', 'engine.inspector.report.columnsEmpty': '还没有列。在下方添加字段。', 'engine.inspector.report.noSchema': '规格 schema 不可用 —— 仅显示基础属性。', + // Trailing section for fields the live server has but the bundled spec lacks. + 'engine.inspector.moreFields': '更多字段', // Dashboard default (home) inspector 'engine.inspector.dashboard.kind': '仪表盘', 'engine.inspector.dashboard.close': '关闭仪表盘', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx index 25d8a901b..48f0714b0 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx @@ -43,16 +43,30 @@ import { WIDGET_TYPE_META, UnknownWidgetIcon } from '../previews/widget-types'; import type { MetadataDefaultInspectorProps } from '../default-inspector-registry'; import { SchemaForm } from '../SchemaForm'; import { getDashboardForm, getDashboardSchema } from '../dashboard-schema'; +import { mergeServerFields } from '../mergeServerFields'; import { t } from '../i18n'; type DashboardWidget = DashboardWidgetSchema & { id: string }; +/** + * Top-level dashboard fields rendered by this inspector's own controls (or by + * the dedicated widgets list), pruned from the spec-form graft so they are not + * double-rendered. Mirrors the `hiddenFields` passed to SchemaForm. + */ +const DASHBOARD_CURATED_FIELDS = new Set([ + 'name', + 'label', + 'description', + 'widgets', +]); + export function DashboardDefaultInspector({ draft, onPatch, readOnly, locale, onSelectionChange, + serverSchema, }: MetadataDefaultInspectorProps) { const tr = React.useCallback((key: string) => t(key, locale), [locale]); @@ -99,8 +113,19 @@ export function DashboardDefaultInspector({ const [dragIndex, setDragIndex] = React.useState(null); const [overIndex, setOverIndex] = React.useState(null); - const schema = getDashboardSchema(); - const form = getDashboardForm(); + // Graft any server-only top-level dashboard fields onto the bundled-spec + // form so they are editable even when the bundled spec lags the server. + const { schema, form } = React.useMemo( + () => + mergeServerFields({ + bundledSchema: getDashboardSchema(), + bundledForm: getDashboardForm(), + serverSchema, + excludeFields: DASHBOARD_CURATED_FIELDS, + sectionTitle: t('engine.inspector.moreFields', locale), + }), + [serverSchema, locale], + ); return ( t(key, locale), [locale]); @@ -176,8 +191,20 @@ export function ReportDefaultInspector({ const [dragIndex, setDragIndex] = React.useState(null); const [overIndex, setOverIndex] = React.useState(null); - const schema = getReportSchema(); - const form = getReportForm(); + // Graft any server-only top-level fields (e.g. dataset/rows/values) onto + // the bundled-spec form so they are directly editable here even when the + // bundled `@objectstack/spec` lags the running server (skew root-cure). + const { schema, form } = React.useMemo( + () => + mergeServerFields({ + bundledSchema: getReportSchema(), + bundledForm: getReportForm(), + serverSchema, + excludeFields: REPORT_CURATED_FIELDS, + sectionTitle: t('engine.inspector.moreFields', locale), + }), + [serverSchema, locale], + ); return ( | undefined) ?? {}; @@ -188,8 +198,27 @@ export function ViewVariantInspector({ [objectFields], ); - const form = isFormFamily ? undefined : getViewForm(); - const schema = isFormFamily ? getFormVariantSchema() : getListVariantSchema(); + // Graft server-only fields onto the bundled variant form so new server + // fields are editable even when the bundled spec lags (skew root-cure). A + // View is a nested document: the variant body lives under + // `serverSchema.properties.{list|form}`, so we pass that sub-schema. + const serverVariantSchema = (() => { + const props = (serverSchema?.properties as Record | undefined); + return (isFormFamily ? props?.form : props?.list) as + | Record + | undefined; + })(); + const { schema, form } = React.useMemo( + () => + mergeServerFields({ + bundledSchema: isFormFamily ? getFormVariantSchema() : getListVariantSchema(), + bundledForm: isFormFamily ? undefined : getViewForm(), + serverSchema: serverVariantSchema, + excludeFields: VIEW_CURATED_FIELDS, + sectionTitle: t('engine.inspector.moreFields', locale), + }), + [isFormFamily, serverVariantSchema, locale], + ); /** Shallow-write a curated patch onto the variant. */ const writeVariant = (patch: Record) => { diff --git a/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts b/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts new file mode 100644 index 000000000..3f4f25e38 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/mergeServerFields.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * mergeServerFields — the curated-form half of the cross-repo spec-skew + * root-cure. Grafts server-only top-level fields onto the bundled-spec form + * so new server fields (e.g. a report's dataset/rows/values) are directly + * editable even when the bundled @objectstack/spec lags the running server. + */ + +import { describe, it, expect } from 'vitest'; +import { mergeServerFields } from './mergeServerFields'; + +const bundledSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + objectName: { type: 'string' }, + columns: { type: 'array' }, + type: { type: 'string' }, + }, +} as Record; + +const bundledForm = { + type: 'simple' as const, + sections: [{ label: 'Basics', fields: [{ field: 'label' }, { field: 'type' }] }], +}; + +// Newer server: adds dataset/rows/values/runtimeFilter on top of the bundle. +const serverSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + label: { type: 'string' }, + objectName: { type: 'string' }, + columns: { type: 'array' }, + type: { type: 'string' }, + dataset: { type: 'string' }, + rows: { type: 'array', items: { type: 'string' } }, + values: { type: 'array', items: { type: 'string' } }, + runtimeFilter: { type: 'object' }, + }, +} as Record; + +const CURATED = new Set(['type', 'objectName', 'label', 'name', 'columns']); + +describe('mergeServerFields', () => { + it('grafts server-only fields into BOTH schema.properties and a trailing form section', () => { + const { schema, form } = mergeServerFields({ + bundledSchema, + bundledForm, + serverSchema, + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + + // schema gains the new props… + expect(schema!.properties).toHaveProperty('dataset'); + expect(schema!.properties).toHaveProperty('rows'); + expect(schema!.properties).toHaveProperty('values'); + expect(schema!.properties).toHaveProperty('runtimeFilter'); + + // …and a trailing section declares exactly those new fields so SchemaForm + // (which renders only declared fields when a form is present) shows them. + const sections = form!.sections!; + expect(sections).toHaveLength(2); + const added = sections[1]; + expect(added.label).toBe('More fields'); + const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + expect(fieldNames).toEqual(['dataset', 'rows', 'values', 'runtimeFilter']); + }); + + it('does not graft curated-owned fields even if the server still has them', () => { + const { schema, form } = mergeServerFields({ + bundledSchema, + bundledForm, + serverSchema, + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + const added = form!.sections![1]; + const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + expect(fieldNames).not.toContain('objectName'); + expect(fieldNames).not.toContain('columns'); + // bundled props/sections are untouched (additive only) + expect(form!.sections![0]).toEqual(bundledForm.sections[0]); + expect(schema!.properties.label).toEqual(bundledSchema.properties.label); + }); + + it('is a no-op when the server schema has no extra fields', () => { + const { schema, form } = mergeServerFields({ + bundledSchema, + bundledForm, + serverSchema: bundledSchema, // identical shape — nothing new + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + expect(schema).toBe(bundledSchema); + expect(form).toBe(bundledForm); + }); + + it('is a no-op when no server schema is provided (offline / older server)', () => { + const { schema, form } = mergeServerFields({ + bundledSchema, + bundledForm, + serverSchema: undefined, + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + expect(schema).toBe(bundledSchema); + expect(form).toBe(bundledForm); + }); + + it('does not re-add a field once the bundle catches up', () => { + // Bundle now ALSO has dataset → it must not be grafted again. + const caughtUp = { + ...bundledSchema, + properties: { ...bundledSchema.properties, dataset: { type: 'string' } }, + }; + const { schema, form } = mergeServerFields({ + bundledSchema: caughtUp, + bundledForm, + serverSchema, + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + const added = form!.sections![1]; + const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + expect(fieldNames).not.toContain('dataset'); + expect(fieldNames).toEqual(['rows', 'values', 'runtimeFilter']); + expect(schema!.properties.dataset).toEqual(caughtUp.properties.dataset); + }); + + it('grafts schema props but synthesises no form when the bundle ships none (flat render)', () => { + const { schema, form } = mergeServerFields({ + bundledSchema, + bundledForm: undefined, + serverSchema, + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + // Flat SchemaForm renders every property, so the schema graft alone surfaces them. + expect(schema!.properties).toHaveProperty('dataset'); + expect(form).toBeUndefined(); + }); + + it('skips fields the bundled form already declares (no duplicate render)', () => { + const formWithDataset = { + type: 'simple' as const, + sections: [{ label: 'Basics', fields: [{ field: 'label' }, { field: 'dataset' }] }], + }; + const { form } = mergeServerFields({ + bundledSchema, + bundledForm: formWithDataset, + serverSchema, + excludeFields: CURATED, + sectionTitle: 'More fields', + }); + const added = form!.sections![1]; + const fieldNames = added.fields.map((f) => (typeof f === 'string' ? f : f.field)); + expect(fieldNames).not.toContain('dataset'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts b/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts new file mode 100644 index 000000000..87545caa4 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/mergeServerFields.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * mergeServerFields — completes the cross-repo spec-skew root-cure for the + * CURATED inspectors (report / dashboard / view). + * + * Background: the curated "home" inspectors derive their authoring form from + * the BUNDLED `@objectstack/spec` (`getReportForm()`/`getReportSchema()` …). + * When the running server adds new fields to a metadata type (e.g. report's + * `dataset`/`rows`/`values`) before objectui bumps its bundled spec, those + * fields exist in the live server schema (`/meta/types` → `entry.schema`) but + * NOT in the bundled one — so the curated form can't render them. They were + * only reachable via the raw "source" tab / API. + * + * `clientValidation.ts` already stopped the bundled spec from being STRICTER + * than the server (no more false "required" banners). This is the symmetric + * other half: let the bundled-derived FORM be a SUPERSET of the bundled spec + * by grafting in any server-only top-level properties, so new fields become + * directly editable in the right-hand curated form. + * + * It is purely ADDITIVE and lag-tolerant: + * - Only fields the server has and the bundle lacks are added; existing + * bundled fields, ordering, sections and `visibleOn` predicates are + * untouched. + * - When the bundle catches up (the field appears in `bundledSchema`), the + * merge becomes a no-op for that field automatically — no shim to remove. + * - When no server schema is available (offline/older server), it returns + * the bundled `{schema, form}` verbatim. + * + * SchemaForm renders ONLY the fields a `form` declares (see SchemaForm's + * sectioned path), so new properties must be added to BOTH the schema's + * `properties` AND the form (as a trailing section) to actually surface. + */ + +import type { FormViewSpec } from './SchemaForm'; + +type JsonSchema = Record; + +export interface MergeServerFieldsArgs { + /** Bundled-spec JSONSchema for the whole document (may be undefined). */ + bundledSchema: JsonSchema | undefined; + /** Bundled-spec authoring FormView (may be undefined). */ + bundledForm: FormViewSpec | undefined; + /** + * Live server JSONSchema for this type (`RichMetadataTypeEntry.schema`). + * For nested documents (view), pass the relevant sub-schema (e.g. + * `serverSchema.properties.list`). + */ + serverSchema: JsonSchema | undefined; + /** + * Top-level fields the curated inspector renders itself (e.g. report's + * `objectName`/`columns`/`label`) plus identity (`name`). These are never + * grafted into the spec form even if the server adds/keeps them, so they + * are not double-edited. + */ + excludeFields: Set; + /** Localized title for the trailing "new server fields" section. */ + sectionTitle: string; +} + +export interface MergeServerFieldsResult { + schema: JsonSchema | undefined; + form: FormViewSpec | undefined; +} + +/** Collect every field name a FormView's sections already declare. */ +function formDeclaredFields(form: FormViewSpec | undefined): Set { + const out = new Set(); + for (const s of form?.sections ?? []) { + for (const f of s.fields ?? []) { + out.add(typeof f === 'string' ? f : (f as any)?.field); + } + } + return out; +} + +/** + * Return `{schema, form}` with server-only top-level properties grafted onto + * the bundled pair. No-op (returns the bundled pair) when there is nothing to + * add or no server schema to read. + */ +export function mergeServerFields({ + bundledSchema, + bundledForm, + serverSchema, + excludeFields, + sectionTitle, +}: MergeServerFieldsArgs): MergeServerFieldsResult { + if (!bundledSchema) return { schema: bundledSchema, form: bundledForm }; + + const serverProps = serverSchema?.properties as + | Record + | undefined; + if (!serverProps || typeof serverProps !== 'object') { + return { schema: bundledSchema, form: bundledForm }; + } + + const bundledProps = (bundledSchema.properties ?? {}) as Record; + const alreadyShown = formDeclaredFields(bundledForm); + + // Server-only fields = on the server, absent from the bundled schema, not + // curated-owned, and not already declared by the bundled form. + const newKeys = Object.keys(serverProps).filter( + (k) => + !(k in bundledProps) && + !excludeFields.has(k) && + !alreadyShown.has(k), + ); + if (newKeys.length === 0) return { schema: bundledSchema, form: bundledForm }; + + // ── additive schema merge (shallow clone; bundled defs win on conflict) ── + const mergedSchema: JsonSchema = { + ...bundledSchema, + properties: { ...bundledProps }, + }; + for (const k of newKeys) { + if (!(k in mergedSchema.properties)) mergedSchema.properties[k] = serverProps[k]; + } + + // ── additive form merge — append a trailing section with the new fields ── + // When the bundle ships no form at all, SchemaForm falls back to a flat + // property list (which already renders every property), so we only need to + // synthesise a form when one exists to preserve its curated layout. + let mergedForm = bundledForm; + if (bundledForm && typeof bundledForm === 'object') { + const clone = JSON.parse(JSON.stringify(bundledForm)) as FormViewSpec; + clone.sections = [ + ...(clone.sections ?? []), + { label: sectionTitle, fields: newKeys.map((field) => ({ field })) }, + ]; + mergedForm = clone; + } + + return { schema: mergedSchema, form: mergedForm }; +}