Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
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
11 changes: 11 additions & 0 deletions .changeset/13459-refuse-invalid-gantt-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@object-ui/plugin-gantt": patch
"@object-ui/app-shell": patch
---

Clause-②: no
Metadata-protocol: narrowed

An invalid `gantt` block is refused instead of drawn (objectui#13459). `ObjectGantt` used to log `Invalid gantt configuration` and render the block anyway, so a block with a misspelt or missing key still drew a chart. It now renders a refusal that names each `GanttConfigSchema` issue by its path in the block (`gantt.endDateField: …`); a valid block and the flat ObjectView / ListView spelling render as before.

The Studio designers' spec derivations (`dashboard`, `package`, `page`, `report` and `view` schemas in `metadata-admin`) no longer catch a failed derivation and return `undefined`, which rendered a designer form silently missing fields. Their only input is the bundled spec, so a failure is a build-time fact that fails their tests; at runtime it reaches the route's error boundary.
66 changes: 29 additions & 37 deletions packages/app-shell/src/views/metadata-admin/dashboard-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,19 @@ const FORM_FIELDS_OWNED_ELSEWHERE = new Set([
'name', // record identity — not user-editable here
]);

/**
* No `try`/`catch` around the spec derivations in this file (objectui#13459).
* Their only input is the bundled `@objectstack/spec`, so a derivation that
* fails is a build-time fact: the tests beside this file go red, and at runtime
* the throw reaches the route's error boundary. It used to be caught, warned
* about in the console and turned into `undefined`, which rendered a designer
* form silently missing fields that the author could then save past.
*/
let _dashboardDocSchema: JsonSchema | undefined;
let _dashboardDocFailed = false;

/** JSONSchema for the whole (flat) Dashboard document. */
export function getDashboardSchema(): JsonSchema | undefined {
if (_dashboardDocSchema || _dashboardDocFailed) return _dashboardDocSchema;
try {
_dashboardDocSchema = z.toJSONSchema(DashboardSchema, TO_JSON_OPTS) as JsonSchema;
} catch (err) {
_dashboardDocFailed = true;
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to derive Dashboard JSONSchema from spec', err);
}
}
export function getDashboardSchema(): JsonSchema {
_dashboardDocSchema ??= z.toJSONSchema(DashboardSchema, TO_JSON_OPTS) as JsonSchema;
return _dashboardDocSchema;
}

Expand All @@ -79,31 +78,24 @@ export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpe
const cacheKey = String(locale ?? '');
if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey);
if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
for (const section of clone.sections ?? []) {
section.fields = (section.fields ?? []).filter((f: any) => {
const name = typeof f === 'string' ? f : f?.field;
return !FORM_FIELDS_OWNED_ELSEWHERE.has(name);
});
}
// Drop now-empty sections so we don't render bare headers.
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err);
}
return undefined;
const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec;
for (const section of clone.sections ?? []) {
section.fields = (section.fields ?? []).filter((f: any) => {
const name = typeof f === 'string' ? f : f?.field;
return !FORM_FIELDS_OWNED_ELSEWHERE.has(name);
});
}
// Drop now-empty sections so we don't render bare headers.
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
// Localize AFTER pruning: the overlay only has to reach what renders, and
// pruning cannot drop a section the overlay just renamed.
const localized = localizeMetadataForm(
clone as unknown as Record<string, unknown>,
'dashboard',
locale,
) as unknown as FormViewSpec;
_dashboardForm.set(cacheKey, localized);
return localized;
}
21 changes: 10 additions & 11 deletions packages/app-shell/src/views/metadata-admin/package-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,19 @@ export const PACKAGE_FORM_FIELDS = [
'dependencies',
] as const;

/**
* No `try`/`catch` around the spec derivations in this file (objectui#13459).
* Their only input is the bundled `@objectstack/spec`, so a derivation that
* fails is a build-time fact: the tests beside this file go red, and at runtime
* the throw reaches the route's error boundary. It used to be caught, warned
* about in the console and turned into `undefined`, which rendered a designer
* form silently missing fields that the author could then save past.
*/
let _schema: JsonSchema | undefined;
let _schemaFailed = false;

/** JSONSchema for the whole package manifest (memoised, spec-derived). */
export function getPackageSchema(): JsonSchema | undefined {
if (_schema || _schemaFailed) return _schema;
try {
_schema = z.toJSONSchema(ManifestSchema, TO_JSON_OPTS) as JsonSchema;
} catch (err) {
_schemaFailed = true;
if (typeof console !== 'undefined') {
console.warn('[package-schema] failed to derive manifest JSONSchema from spec', err);
}
}
export function getPackageSchema(): JsonSchema {
_schema ??= z.toJSONSchema(ManifestSchema, TO_JSON_OPTS) as JsonSchema;
return _schema;
}

Expand Down
44 changes: 18 additions & 26 deletions packages/app-shell/src/views/metadata-admin/page-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,19 @@ const TO_JSON_OPTS = { io: 'input', unrepresentable: 'any' } as const;
*/
export const PAGE_FIELDS_OWNED_ELSEWHERE = new Set(['name', 'regions', 'children']);

/**
* No `try`/`catch` around the spec derivations in this file (objectui#13459).
* Their only input is the bundled `@objectstack/spec`, so a derivation that
* fails is a build-time fact: the tests beside this file go red, and at runtime
* the throw reaches the route's error boundary. It used to be caught, warned
* about in the console and turned into `undefined`, which rendered a designer
* form silently missing fields that the author could then save past.
*/
let _pageSchema: JsonSchema | undefined;
let _pageSchemaFailed = false;

/** JSONSchema for the whole Page document (memoised, spec-derived). */
export function getPageSchema(): JsonSchema | undefined {
if (_pageSchema || _pageSchemaFailed) return _pageSchema;
try {
_pageSchema = z.toJSONSchema(PageSchema, TO_JSON_OPTS) as JsonSchema;
} catch (err) {
_pageSchemaFailed = true;
if (typeof console !== 'undefined') {
console.warn('[page-schema] failed to derive Page JSONSchema from spec', err);
}
}
export function getPageSchema(): JsonSchema {
_pageSchema ??= z.toJSONSchema(PageSchema, TO_JSON_OPTS) as JsonSchema;
return _pageSchema;
}

Expand All @@ -67,21 +66,14 @@ let _pageForm: FormViewSpec | undefined;
export function getPageForm(): FormViewSpec | undefined {
if (_pageForm) return _pageForm;
if (!specPageForm || typeof specPageForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specPageForm)) as FormViewSpec;
for (const section of clone.sections ?? []) {
section.fields = (section.fields ?? []).filter((f: any) => {
const name = typeof f === 'string' ? f : f?.field;
return !PAGE_FIELDS_OWNED_ELSEWHERE.has(name);
});
}
clone.sections = (clone.sections ?? []).filter((s) => (s.fields ?? []).length > 0);
_pageForm = clone;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[page-schema] failed to prune Page authoring form from spec', err);
}
_pageForm = specPageForm as FormViewSpec;
const clone = JSON.parse(JSON.stringify(specPageForm)) as FormViewSpec;
for (const section of clone.sections ?? []) {
section.fields = (section.fields ?? []).filter((f: any) => {
const name = typeof f === 'string' ? f : f?.field;
return !PAGE_FIELDS_OWNED_ELSEWHERE.has(name);
});
}
clone.sections = (clone.sections ?? []).filter((s) => (s.fields ?? []).length > 0);
_pageForm = clone;
return _pageForm;
}
92 changes: 42 additions & 50 deletions packages/app-shell/src/views/metadata-admin/report-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,19 @@ const FORM_FIELDS_OWNED_ELSEWHERE = new Set([
'name', // record identity — not user-editable here
]);

/**
* No `try`/`catch` around the spec derivations in this file (objectui#13459).
* Their only input is the bundled `@objectstack/spec`, so a derivation that
* fails is a build-time fact: the tests beside this file go red, and at runtime
* the throw reaches the route's error boundary. It used to be caught, warned
* about in the console and turned into `undefined`, which rendered a designer
* form silently missing fields that the author could then save past.
*/
let _reportDocSchema: JsonSchema | undefined;
let _reportDocFailed = false;

/** JSONSchema for the whole (flat) Report document. */
export function getReportSchema(): JsonSchema | undefined {
if (_reportDocSchema || _reportDocFailed) return _reportDocSchema;
try {
_reportDocSchema = z.toJSONSchema(ReportSchema, TO_JSON_OPTS) as JsonSchema;
} catch (err) {
_reportDocFailed = true;
if (typeof console !== 'undefined') {
console.warn('[report-schema] failed to derive Report JSONSchema from spec', err);
}
}
export function getReportSchema(): JsonSchema {
_reportDocSchema ??= z.toJSONSchema(ReportSchema, TO_JSON_OPTS) as JsonSchema;
return _reportDocSchema;
}

Expand Down Expand Up @@ -80,48 +79,41 @@ let _reportForm: FormViewSpec | undefined;
export function getReportForm(): FormViewSpec | undefined {
if (_reportForm) return _reportForm;
if (!specReportForm || typeof specReportForm !== 'object') return undefined;
try {
const clone = JSON.parse(JSON.stringify(specReportForm)) as FormViewSpec;
const schemaProps = getReportSchema()?.properties as Record<string, unknown> | undefined;
for (const section of clone.sections ?? []) {
section.fields = (section.fields ?? []).filter((f: any) => {
const name = typeof f === 'string' ? f : f?.field;
if (FORM_FIELDS_OWNED_ELSEWHERE.has(name)) return false;
// Schema-subset pass: drop form fields the schema no longer carries.
if (schemaProps && !(name in schemaProps)) return false;
return true;
});
}
if (schemaProps && 'runtimeFilter' in schemaProps) {
const declared = new Set<string>();
for (const s of clone.sections ?? []) {
for (const f of s.fields ?? []) {
declared.add(typeof f === 'string' ? f : (f as any)?.field);
}
}
if (!declared.has('runtimeFilter')) {
clone.sections = [
...(clone.sections ?? []),
{
label: 'Filter',
description: 'Render-time scope filter, ANDed at query time.',
collapsible: true,
collapsed: true,
fields: [{ field: 'runtimeFilter', widget: 'json' }],
},
];
const clone = JSON.parse(JSON.stringify(specReportForm)) as FormViewSpec;
const schemaProps = getReportSchema().properties as Record<string, unknown> | undefined;
for (const section of clone.sections ?? []) {
section.fields = (section.fields ?? []).filter((f: any) => {
const name = typeof f === 'string' ? f : f?.field;
if (FORM_FIELDS_OWNED_ELSEWHERE.has(name)) return false;
// Schema-subset pass: drop form fields the schema no longer carries.
if (schemaProps && !(name in schemaProps)) return false;
return true;
});
}
if (schemaProps && 'runtimeFilter' in schemaProps) {
const declared = new Set<string>();
for (const s of clone.sections ?? []) {
for (const f of s.fields ?? []) {
declared.add(typeof f === 'string' ? f : (f as any)?.field);
}
}
// Drop now-empty sections so we don't render bare headers.
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_reportForm = clone;
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[report-schema] failed to prepare reportForm from spec', err);
if (!declared.has('runtimeFilter')) {
clone.sections = [
...(clone.sections ?? []),
{
label: 'Filter',
description: 'Render-time scope filter, ANDed at query time.',
collapsible: true,
collapsed: true,
fields: [{ field: 'runtimeFilter', widget: 'json' }],
},
];
}
return undefined;
}
// Drop now-empty sections so we don't render bare headers.
clone.sections = (clone.sections ?? []).filter(
(s: any) => (s.fields ?? []).length > 0,
);
_reportForm = clone;
return _reportForm;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#13459 — the designer forms' spec derivations fail LOUDLY.
*
* The five `*-schema.ts` modules derive their JSONSchema from the bundled
* `@objectstack/spec`. That input is fixed at build time, so a derivation that
* fails is a build-time fact: this file goes red for it. At runtime the throw
* reaches the route's error boundary instead of being caught, warned about and
* turned into `undefined` — which used to render a designer form silently
* missing fields.
*
* Two halves: every derivation succeeds against the bundled spec, and a
* derivation that throws reaches the caller instead of becoming `undefined`.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';

const DERIVATIONS = [
['dashboard-schema', 'getDashboardSchema'],
['package-schema', 'getPackageSchema'],
['page-schema', 'getPageSchema'],
['report-schema', 'getReportSchema'],
['view-schema', 'getListVariantSchema'],
['view-schema', 'getFormVariantSchema'],
['view-schema', 'getListColumnSchema'],
] as const;

const LOADERS: Record<(typeof DERIVATIONS)[number][0], () => Promise<unknown>> = {
'dashboard-schema': () => import('./dashboard-schema'),
'package-schema': () => import('./package-schema'),
'page-schema': () => import('./page-schema'),
'report-schema': () => import('./report-schema'),
'view-schema': () => import('./view-schema'),
};

/** A fresh module instance per case, so a memoised result cannot answer. */
async function derive(file: (typeof DERIVATIONS)[number][0], getter: string): Promise<unknown> {
vi.resetModules();
const mod = (await LOADERS[file]()) as Record<string, () => unknown>;
return mod[getter]();
}

// `vi.doMock`, not a hoisted `vi.mock`: the failing `toJSONSchema` is scoped to
// the cases that ask for it, and undone after each, so it cannot reach another
// test file sharing this worker's module registry.
afterEach(() => {
vi.doUnmock('zod');
vi.resetModules();
});

describe('designer spec derivations (objectui#13459)', () => {
it.each(DERIVATIONS)('%s.%s derives an object schema from the bundled spec', async (file, getter) => {
const schema = (await derive(file, getter)) as { type?: unknown; properties?: unknown } | undefined;
expect(schema).toBeDefined();
expect(schema?.type).toBe('object');
expect(schema?.properties).toBeTypeOf('object');
});

it.each(DERIVATIONS)('%s.%s throws when the derivation fails, never returns undefined', async (file, getter) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.doMock('zod', async (importOriginal) => {
const actual = await importOriginal<typeof import('zod')>();
const toJSONSchema = (): never => {
throw new Error('derivation failed (test)');
};
return { ...actual, z: { ...actual.z, toJSONSchema } };
});
await expect(derive(file, getter)).rejects.toThrow('derivation failed (test)');
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
Loading
Loading