diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index b8e252ddaf..742e91dc82 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -119,11 +119,14 @@ function isNamedItem(item: unknown): item is { name: string } { * Merge `view` metadata into object definitions so that `objectDef.listViews` * is populated for the renderer (`@object-ui/plugin-view`) which expects it. * - * Two view shapes coexist in the `view` metadata type (the backend returns - * both for back-compat — see framework `objectql/engine.ts` registration): + * Two view shapes coexist in the `view` metadata type — **both deliberate, and + * neither one a leftover** (objectstack#4959 settled this end-to-end; see + * framework `objectql/engine.ts` registration). Each belongs to a different + * authoring gate, so this merge has to read both: * - * 1. Independent **ViewItem** (ADR-0017, "Object has-many View") — the - * canonical first-class shape, one entry per named view: + * 1. Independent **ViewItem** — the RECORD gate (ADR-0017, "Object has-many + * View"): one first-class metadata record per named view, as authored by + * `defineViewItem` or written through the runtime `/meta` seam. * { name: '.', object, viewKind: 'list' | 'form', * label, isDefault?, config: { type, data, columns, … } } * `viewKind` is the family discriminant and the view body lives under @@ -131,11 +134,16 @@ function isNamedItem(item: unknown): item is { name: string } { * `viewKind: 'form'` items into `formViews` — so FORM-family views never * surface in the list-view switcher (which only reads `listViews`). * - * 2. Legacy aggregated **container** `{ list?, form?, listViews?, formViews? }` - * keyed by the bare object name. Kept for adapters/fixtures that don't - * expand into ViewItems. When an object already has expanded ViewItems the - * container is skipped, since it restates the same views (and keying both - * would list every view twice — once under its short key, once under its + * 2. Aggregated **container** `{ list?, form?, listViews?, formViews? }` keyed + * by the bare object name — the STACK gate's packaging shape: what + * `defineView` emits and what a stack carries in + * `defineStack({ views: [...] })`. The spec treats it as first class + * (`isAggregatedViewContainer` / `expandViewContainer` in + * `@objectstack/spec/ui`), so it is NOT legacy and this branch is NOT dead + * code — delete it and stack-packaged views stop reaching the renderer. + * When an object already has expanded ViewItems the container is skipped + * for THAT object, since it restates the same views (and keying both would + * list every view twice — once under its short key, once under its * canonical `.` name). * * Existing `obj.listViews` / `obj.list_views` win to preserve overrides. @@ -155,14 +163,15 @@ function isViewItem(view: any): boolean { export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] { if (!objects.length || !views.length) return objects; const byObject: Record = {}; - // Objects that received expanded ViewItems — their legacy aggregated - // container (also present in the `view` list) is superseded and skipped. + // Objects that received expanded ViewItems — the aggregated container for + // those objects (also present in the `view` list) restates the same views, so + // it is skipped per-object. Other objects still depend on it. const hasViewItems = new Set(); for (const view of views) { if (isViewItem(view)) hasViewItems.add(view.object); } for (const view of views) { - // ── New protocol: independent ViewItem ({ name, object, viewKind, config }) ── + // ── Record gate: independent ViewItem ({ name, object, viewKind, config }) ── if (isViewItem(view)) { const bucket = (byObject[view.object] ||= { listViews: {}, formViews: {} }); // Canonical `.` name doubles as the view id, so `/view/` @@ -183,7 +192,7 @@ export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] { } continue; } - // ── Legacy aggregated container ({ list, form, listViews, formViews }) ── + // ── Stack gate: aggregated container ({ list, form, listViews, formViews }) ── const objName = view?.name || view?.list?.data?.object || view?.form?.data?.object; if (!objName) continue; // Expanded ViewItems supersede the bare container for this object. diff --git a/packages/app-shell/src/views/runtime-metadata-persistence.viewItemSpec.test.ts b/packages/app-shell/src/views/runtime-metadata-persistence.viewItemSpec.test.ts new file mode 100644 index 0000000000..2929b70972 --- /dev/null +++ b/packages/app-shell/src/views/runtime-metadata-persistence.viewItemSpec.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// ViewItem spec conformance for the runtime persistence seam (objectui#3375, +// tail of objectui#3312 / objectstack#4959). +// +// `viewEnvelope` is the app-shell's SECOND ViewItem producer. The first one — +// `createBuildBody` for the metadata-admin `view` resource — is already pinned +// against the real spec by `metadata-admin/view-create-body.test.ts`; this file +// is the same-shaped pin for the runtime "create / save as view" path. +// +// Why a separate pin rather than more `toEqual` cases in +// `runtime-metadata-persistence.test.ts`: the sibling suite asserts the +// envelope's *literal* shape (which keys land where), which stays green even if +// `@objectstack/spec` tightens `ViewItemSchema` underneath us. This file asserts +// the envelope is something the RECORD GATE (ADR-0017 ViewItem) actually +// accepts, so a spec tightening surfaces here instead of at publish time. +// +// This is a ratchet, not a bug repro — every case below is expected GREEN on +// today's producer. A red here means either the producer drifted off-spec or +// the spec moved; read the surfaced zod issues before touching either. +// +// Fixtures are the two real call sites' payloads, not invented shapes: +// * `ObjectView.handleViewCreate` — CreateViewDialog's `{ type, label, name, +// [type]: subConfig }` payload, plus the kanban/gallery column massaging +// that call site performs before handing the spec to `viewEnvelope`. +// * `ObjectDataPage.handleSaveAsView` — the current list config plus the +// resolved column list. + +import { describe, it, expect } from 'vitest'; +import { ViewItemSchema } from '@objectstack/spec/ui'; +import { viewEnvelope } from './runtime-metadata-persistence'; + +/** Assert a produced envelope passes the real spec gate, surfacing zod's issues. */ +function expectSpecValid(body: unknown) { + const res = ViewItemSchema.safeParse(body); + expect( + res.success, + `ViewItem rejected by spec: ${JSON.stringify(res.error?.issues)}\nbody=${JSON.stringify(body)}`, + ).toBe(true); +} + +// The default columns `ObjectView` prefills via `defaultListColumnsFromObject`. +const COLUMNS = ['name', 'stage', 'amount']; + +describe('viewEnvelope output conforms to the spec ViewItem gate (objectui#3375)', () => { + it('produces a spec-valid list ViewItem for the default grid create', () => { + const env = viewEnvelope( + 'crm_task', + { type: 'grid', label: 'My Grid', name: 'my_grid', columns: COLUMNS }, + { name: 'my_grid', label: 'My Grid' }, + ); + expect(env.viewKind).toBe('list'); + expect(env.name).toBe('crm_task.my_grid'); + expectSpecValid(env); + }); + + it('produces a spec-valid ViewItem for a kanban create (sub-config + mirrored columns)', () => { + // `ObjectView` mirrors the resolved column list into `spec.kanban.columns` + // before calling the seam; `groupByField` comes from CreateViewDialog's + // REQUIRED_FIELDS_BY_TYPE. Both are required by the spec's kanban config. + const env = viewEnvelope( + 'crm_task', + { + type: 'kanban', + label: 'Board', + name: 'board', + columns: COLUMNS, + kanban: { groupByField: 'stage', columns: COLUMNS }, + }, + { name: 'board', label: 'Board' }, + ); + expectSpecValid(env); + }); + + it('produces a spec-valid ViewItem for a gallery create (visibleFields mirror)', () => { + const env = viewEnvelope( + 'crm_task', + { + type: 'gallery', + label: 'Cards', + name: 'cards', + columns: COLUMNS, + gallery: { visibleFields: COLUMNS }, + }, + { name: 'cards', label: 'Cards' }, + ); + expectSpecValid(env); + }); + + it('produces a spec-valid ViewItem for a calendar create (multi-key sub-config)', () => { + const env = viewEnvelope( + 'crm_task', + { + type: 'calendar', + label: 'Schedule', + name: 'schedule', + columns: COLUMNS, + calendar: { startDateField: 'due_date', titleField: 'name' }, + }, + { name: 'schedule', label: 'Schedule' }, + ); + expectSpecValid(env); + }); + + it('produces a spec-valid ViewItem when the spec carries folded filter rules', () => { + // `viewFilterFold.foldFilterGroupToSpecRules` is the builder's exit into + // spec vocabulary: `{ field, operator, value }` with the operator already + // canonicalised by `normalizeFilterOperator` (so `equals`, never `=`). + const env = viewEnvelope( + 'crm_task', + { + type: 'grid', + columns: ['name'], + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + }, + { name: 'mine', label: 'Mine' }, + ); + expectSpecValid(env); + }); + + it('stamps a spec-valid config.data binding while preserving caller data keys', () => { + const env = viewEnvelope( + 'acct', + { type: 'grid', columns: [], data: { pageSize: 25 } }, + { name: 'big', label: 'Big' }, + ); + expect(env.config.data).toEqual({ provider: 'object', pageSize: 25, object: 'acct' }); + expectSpecValid(env); + }); + + it('keeps the last-resort CJK-label key spec-valid (#2767 P5 fallback path)', () => { + // `slugify('看板')` is empty, so `deriveViewKey` mints `_`. + // That synthetic key still has to satisfy `ViewItemNameSchema`. + const env = viewEnvelope( + 'crm_task', + { type: 'kanban', columns: COLUMNS, kanban: { groupByField: 'stage', columns: COLUMNS } }, + { label: '看板' }, + ); + expect(env.name).toMatch(/^crm_task\.kanban_[a-z0-9]+$/); + expectSpecValid(env); + }); +}); + +describe('viewEnvelope identity boundary the ViewItem gate enforces (objectui#3375)', () => { + it('cannot mint a spec-valid ViewItem without an object binding', () => { + // Documented boundary, not an endorsement: `ViewItemNameSchema` requires a + // dotted `.` name, so an envelope built with no object name + // yields a bare key the record gate rejects. Both call sites resolve an + // object before reaching the seam; this pins WHY that precondition is load + // bearing, so a future caller that drops it fails loudly here. + const env = viewEnvelope(undefined, { type: 'grid', columns: COLUMNS }, { name: 'orphan' }); + expect(env.name).toBe('orphan'); + const res = ViewItemSchema.safeParse(env); + expect(res.success).toBe(false); + expect(res.error?.issues.some((i) => i.path.join('.') === 'name')).toBe(true); + }); +});