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
35 changes: 22 additions & 13 deletions packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,23 +119,31 @@ 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>.<key>', object, viewKind: 'list' | 'form',
* label, isDefault?, config: { type, data, columns, … } }
* `viewKind` is the family discriminant and the view body lives under
* `config`. We route `viewKind: 'list'` items into `listViews` and
* `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 `<object>.<key>` name).
*
* Existing `obj.listViews` / `obj.list_views` win to preserve overrides.
Expand All @@ -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<string, ViewBucket> = {};
// 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<string>();
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 `<object>.<key>` name doubles as the view id, so `/view/<name>`
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<type>_<base36>`.
// 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 `<object>.<key>` 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);
});
});
Loading