diff --git a/.changeset/spec-view-configs.md b/.changeset/spec-view-configs.md new file mode 100644 index 000000000..4a51751d7 --- /dev/null +++ b/.changeset/spec-view-configs.md @@ -0,0 +1,40 @@ +--- +"@object-ui/types": patch +"@object-ui/plugin-list": patch +"@object-ui/plugin-view": patch +"@object-ui/app-shell": patch +--- + +fix(views): the five per-view-type configs speak the spec vocabulary (#2231 phase 3) + +`kanban`/`calendar`/`gantt`/`gallery`/`timeline` on `ListViewSchema` were the last +hand-written forks left after #2882 — and the fork was not cosmetic: objectui named +the same concepts differently from `@objectstack/spec/ui`, and several read-sites +only understood one of the two dialects. Two of those gaps were live bugs. + +**Kanban lanes ignored the spec key.** `ListView` gated the Kanban tab on +`groupByField || groupField` but rendered lanes off `groupField` alone. A config +authored with the spec key — which is exactly what the product's own +`CreateViewDialog` emits — offered the tab and then grouped by whatever +`detectStatusField()` guessed. The spec's `columns` (the fields shown on each card) +was also spread onto the board verbatim, where `columns` means *lanes*, so +`ObjectKanban` built lanes with `undefined` id and title. `columns` now maps to +`cardFields` and the vocabulary keys are stripped from the passthrough. + +**Timeline lost every spec key in app-shell.** `ObjectView`'s `timeline` branch was +a three-key whitelist while its `gallery`/`gantt` siblings had already been fixed to +spread-first, so a stored `timeline: { startDateField, endDateField, groupByField, +colorField, scale }` arrived with only `titleField` and an axis pinned to the +`'due_date'` fallback. + +Also: `plugin-view`'s `ObjectView` now reads `gallery.coverField` and +`timeline.startDateField` (it only understood the legacy aliases), and the dead +`gallery.subtitleField` is removed — three producers computed it and `ObjectGallery` +never read it. + +The schema side now derives from the spec configs (`.partial()`, since the product +authors partial configs and spec marks `columns`/`titleField`/`startDateField` +required). `gantt` needed no local schema at all. The pre-#2231 names +(`groupField`, `cardFields`, `imageField`, `dateField`) remain accepted as deprecated +aliases so stored views keep validating; the spec key wins wherever both appear. +`calendar.defaultView` stays local — it has no spec counterpart. diff --git a/packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx b/packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx index a8b70ec26..bba6273b4 100644 --- a/packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx @@ -28,12 +28,12 @@ const taskObject = { describe('InterfaceListPage default viz bindings', () => { it('kanban: picks the first select field as the group field (both aliases)', () => { - expect(defaultKanbanFromObject(taskObject)).toEqual({ groupField: 'status', groupByField: 'status' }); + expect(defaultKanbanFromObject(taskObject)).toEqual({ groupByField: 'status' }); }); it('kanban: falls back to a status-like field name when no select type exists', () => { const obj = { fields: { title: { type: 'text' }, stage: { type: 'text' } } }; - expect(defaultKanbanFromObject(obj)).toEqual({ groupField: 'stage', groupByField: 'stage' }); + expect(defaultKanbanFromObject(obj)).toEqual({ groupByField: 'stage' }); }); it('kanban: undefined when nothing groupable', () => { @@ -50,7 +50,7 @@ describe('InterfaceListPage default viz bindings', () => { it('ignores hidden and system fields', () => { const obj = { fields: { created_at: { type: 'datetime' }, hidden_sel: { type: 'select', hidden: true }, real_sel: { type: 'select' } } }; - expect(defaultKanbanFromObject(obj)).toEqual({ groupField: 'real_sel', groupByField: 'real_sel' }); + expect(defaultKanbanFromObject(obj)).toEqual({ groupByField: 'real_sel' }); }); }); diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index 33800a700..caaaf9156 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -132,13 +132,14 @@ const SELECT_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean const DATE_TYPES = new Set(['date', 'datetime', 'time']); const IMAGE_TYPES = new Set(['image', 'file', 'attachment', 'avatar', 'photo']); -export function defaultKanbanFromObject(objectDef: any): { groupField: string; groupByField: string } | undefined { +export function defaultKanbanFromObject(objectDef: any): { groupByField: string } | undefined { const field = firstFieldMatching(objectDef, (_n, f) => SELECT_TYPES.has(f.type)) ?? firstFieldMatching(objectDef, (n) => /status|stage|state|priority|category|kind/i.test(n)); - // ListView reads `groupField` to render and `groupByField || groupField` - // to decide the viz is available — set both so it resolves either way. - return field ? { groupField: field, groupByField: field } : undefined; + // `groupByField` is the spec key. This used to emit the legacy `groupField` + // alongside it because ListView rendered off the alias only — that read-site + // now prefers the spec key, so one key is enough. + return field ? { groupByField: field } : undefined; } function defaultDateField(objectDef: any): string | undefined { diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 93f5fd27a..8bb038499 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1478,7 +1478,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an defaultView: viewDef.calendar?.defaultView, }, timeline: { - dateField: viewDef.timeline?.dateField || 'due_date', + // Spread the full view-defined timeline config first so the spec + // fields (startDateField/endDateField/groupByField/colorField/scale) + // survive; then layer the defaults. (Mirrors the gallery and gantt + // branches — a bare whitelist here was dropping every spec key and + // pinning the axis to the legacy `dateField` fallback.) + ...(viewDef.timeline || {}), + startDateField: viewDef.timeline?.startDateField || viewDef.timeline?.dateField || 'due_date', titleField: viewDef.timeline?.titleField || objectDef.titleField || 'name', descriptionField: viewDef.timeline?.descriptionField, }, @@ -1499,7 +1505,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an imageField: viewDef.gallery?.imageField || viewDef.gallery?.coverField || 'image', coverField: viewDef.gallery?.coverField || viewDef.gallery?.imageField, titleField: viewDef.gallery?.titleField || objectDef.titleField || 'name', - subtitleField: viewDef.gallery?.subtitleField, }, gantt: { // Spread the full view-defined gantt config first so the diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 1ff0116ce..c4e7349da 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -779,12 +779,16 @@ export const ListView = React.forwardRef(({ const collectViewFields = (v: any) => { if (!v) return; const candidates = [ - v.groupField, v.groupBy, + // Spec keys first, then the legacy objectui aliases (#2231). + v.groupByField, v.groupField, v.groupBy, + v.summarizeField, v.titleField, v.cardTitle, v.startDateField, v.endDateField, v.dateField, v.endField, v.colorField, v.allDayField, - v.coverField, v.imageField, v.subtitleField, + v.coverField, v.imageField, v.swimlaneField, v.valueField, + // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). + ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), ...(Array.isArray(v.metaFields) ? v.metaFields : []), @@ -994,12 +998,16 @@ export const ListView = React.forwardRef(({ const collectViewFields = (v: any) => { if (!v) return; const candidates = [ - v.groupField, v.groupBy, + // Spec keys first, then the legacy objectui aliases (#2231). + v.groupByField, v.groupField, v.groupBy, + v.summarizeField, v.titleField, v.cardTitle, v.startDateField, v.endDateField, v.dateField, v.endField, v.colorField, v.allDayField, - v.coverField, v.imageField, v.subtitleField, + v.coverField, v.imageField, v.swimlaneField, v.valueField, + // Spec `columns` = the fields shown on each kanban card (legacy: cardFields). + ...(Array.isArray(v.columns) ? v.columns : []), ...(Array.isArray(v.cardFields) ? v.cardFields : []), ...(Array.isArray(v.visibleFields) ? v.visibleFields : []), ...(Array.isArray(v.metaFields) ? v.metaFields : []), @@ -1338,28 +1346,34 @@ export const ListView = React.forwardRef(({ ...((schema as any).bulkActionDefs ? { bulkActionDefs: (schema as any).bulkActionDefs } : {}), ...(schema.options?.grid || {}), }; - case 'kanban': + case 'kanban': { + // The spec's lane field is `groupByField`; `groupField` is the legacy + // objectui alias. Read the canonical key FIRST — reading only the alias + // meant a spec-authored config (what CreateViewDialog emits) passed the + // capability gate above but rendered lanes from the detector instead. + // ADR-0085: with neither key set, fall back to the object's declared + // lifecycle (`stageField`, incl. strict-false suppression) via the shared + // detector — mirrors ObjectView's default so both entry paths agree. + // objectDef loads async: until it lands this stays undefined and the board + // re-derives lanes once it does. + const kanbanCfg = { ...(schema.options?.kanban || {}), ...(schema.kanban || {}) }; + // `columns` is the spec's list of fields shown on each card. ObjectKanban's + // own `columns` prop is its LANES, so passing this through verbatim built + // lanes with undefined id/title. Map it to `cardFields` and strip the + // vocabulary keys from the passthrough (mirrors plugin-view's adapter). + const { columns: kanbanCardColumns, groupByField, groupField, cardFields, titleField, ...restKanban } = kanbanCfg as Record; + const laneField = groupByField || groupField || detectStatusField(objectDef) || undefined; return { type: 'object-kanban', ...baseProps, - // ADR-0085: no explicit lane field → the object's declared - // lifecycle (`stageField`, incl. strict-false suppression) via the - // shared detector — mirrors ObjectView's default so a schema that - // omits groupField behaves the same on both entry paths. objectDef - // loads async: until it lands this stays undefined and the board - // re-derives lanes once it does. - groupBy: schema.kanban?.groupField || schema.options?.kanban?.groupField - || detectStatusField(objectDef) || undefined, - groupField: schema.kanban?.groupField || schema.options?.kanban?.groupField - || detectStatusField(objectDef) || undefined, - ...(schema.kanban?.titleField || schema.options?.kanban?.titleField - ? { titleField: schema.kanban?.titleField || schema.options?.kanban?.titleField } - : {}), - cardFields: schema.kanban?.cardFields || effectiveFields || [], + groupBy: laneField, + groupField: laneField, + ...(titleField ? { titleField } : {}), + cardFields: cardFields || kanbanCardColumns || effectiveFields || [], ...(groupingConfig ? { grouping: groupingConfig } : {}), - ...(schema.options?.kanban || {}), - ...(schema.kanban || {}), + ...restKanban, }; + } case 'calendar': return { type: 'object-calendar', @@ -1390,7 +1404,6 @@ export const ListView = React.forwardRef(({ // Deprecated top-level props for backward compat imageField: schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField, titleField: schema.gallery?.titleField || schema.options?.gallery?.titleField || 'name', - subtitleField: schema.gallery?.subtitleField || schema.options?.gallery?.subtitleField, ...(groupingConfig ? { grouping: groupingConfig } : {}), }; } diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index b1125a1d5..472195a5d 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -30,7 +30,6 @@ export interface ObjectGalleryProps { imageField?: string; /** @deprecated Use gallery.titleField instead */ titleField?: string; - subtitleField?: string; }; data?: Record[]; dataSource?: { find: (name: string, query: unknown) => Promise }; diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index a7cdcb790..dbbf5ee3d 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -2549,3 +2549,130 @@ describe('ListView — gantt view fed by an api-provider ViewData', () => { expect(mockDataSource.find).toHaveBeenCalled(); }); }); + +// ============================================================================ +// Kanban — spec vocabulary reaches the board (#2231) +// ============================================================================ +describe('ListView — kanban config speaks the spec vocabulary', () => { + // The spec lane key is `groupByField`; `groupField` is the legacy objectui + // alias. ListView used to gate the Kanban tab on `groupByField || groupField` + // but render lanes off `groupField` ALONE, so a spec-authored config — what + // CreateViewDialog emits — offered the tab and then grouped by the detector's + // guess. And because the whole config was spread onto the component props, + // the spec's `columns` (fields shown on each card) landed on ObjectKanban's + // `columns` prop, which is its LANES, producing lanes with undefined ids. + let prevObjectKanban: ReturnType; + let kanbanCalls: any[]; + + beforeAll(() => { + prevObjectKanban = ComponentRegistry.get('object-kanban'); + ComponentRegistry.register('object-kanban', (props: any) => { + kanbanCalls.push(props); + return
; + }); + }); + afterAll(() => { + if (prevObjectKanban) { + ComponentRegistry.register('object-kanban', prevObjectKanban); + } else { + ComponentRegistry.unregister('object-kanban'); + } + }); + beforeEach(() => { + kanbanCalls = []; + mockDataSource.find.mockClear(); + }); + + const kanbanSchema = (kanban: Record) => ({ + type: 'list-view', + objectName: 'contacts', + viewType: 'kanban', + fields: ['name'], + kanban, + } as unknown as ListViewSchema); + + it('groups by the spec `groupByField`', async () => { + renderWithProvider(); + expect(await screen.findByTestId('kanban-stub')).toBeInTheDocument(); + + const last = kanbanCalls.at(-1); + expect(last?.schema?.groupBy).toBe('stage'); + expect(last?.schema?.groupField).toBe('stage'); + }); + + it('still honors the deprecated `groupField` alias', async () => { + renderWithProvider(); + expect(await screen.findByTestId('kanban-stub')).toBeInTheDocument(); + + expect(kanbanCalls.at(-1)?.schema?.groupBy).toBe('priority'); + }); + + it('lets the spec key win when both are present', async () => { + renderWithProvider( + , + ); + expect(await screen.findByTestId('kanban-stub')).toBeInTheDocument(); + + expect(kanbanCalls.at(-1)?.schema?.groupBy).toBe('stage'); + }); + + it('maps the spec `columns` to card fields instead of leaking it as lanes', async () => { + renderWithProvider( + , + ); + expect(await screen.findByTestId('kanban-stub')).toBeInTheDocument(); + + const last = kanbanCalls.at(-1); + expect(last?.schema?.cardFields).toEqual(['name', 'amount']); + // `columns` on ObjectKanban means lanes — the view config's `columns` must + // not reach it, or the board renders lanes with undefined id/title. + expect(last?.schema?.columns).toBeUndefined(); + }); + + it('does not leak the vocabulary keys onto the component schema', async () => { + renderWithProvider( + , + ); + expect(await screen.findByTestId('kanban-stub')).toBeInTheDocument(); + + const last = kanbanCalls.at(-1); + expect(last?.schema?.groupByField).toBeUndefined(); + // Unrecognized spec fields still pass through for the renderer to consume. + expect(last?.schema?.summarizeField).toBe('amount'); + }); +}); + +// ============================================================================ +// Field collection covers the spec vocabulary (#2231) +// ============================================================================ +describe('ListView — $select collects fields named by the spec vocabulary', () => { + // The per-view-type configs name fields the view must fetch. The collector + // only knew the legacy objectui keys, so a spec-authored config selected + // neither its lane field nor its card fields — the board then rendered from + // rows that never carried them. + beforeEach(() => { + mockDataSource.find.mockClear(); + mockDataSource.find.mockResolvedValue([]); + }); + + it('selects the kanban lane and card fields named with spec keys', async () => { + const schema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'kanban', + fields: ['name'], + kanban: { groupByField: 'stage', columns: ['owner', 'amount'], summarizeField: 'amount' }, + } as unknown as ListViewSchema; + + renderWithProvider(); + + await vi.waitFor(() => expect(mockDataSource.find).toHaveBeenCalled()); + const selected = JSON.stringify(mockDataSource.find.mock.calls); + for (const field of ['stage', 'owner', 'amount']) { + expect(selected).toContain(field); + } + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 0022da3cb..a1899ecee 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -766,16 +766,18 @@ export const ObjectView: React.FC = ({ return { type: 'object-gallery', ...baseProps, - imageField: viewOptions.gallery?.imageField, + // `coverField` is the spec key; `imageField` is the legacy alias that + // ObjectGallery still consults as a flat prop. + imageField: viewOptions.gallery?.coverField || viewOptions.gallery?.imageField, titleField: viewOptions.gallery?.titleField || 'name', - subtitleField: viewOptions.gallery?.subtitleField, ...(viewOptions.gallery || {}), }; case 'timeline': return { type: 'object-timeline', ...baseProps, - dateField: viewOptions.timeline?.dateField || 'created_at', + // `startDateField` is the spec key; `dateField` is the legacy alias. + startDateField: viewOptions.timeline?.startDateField || viewOptions.timeline?.dateField || 'created_at', titleField: viewOptions.timeline?.titleField || 'name', ...(viewOptions.timeline || {}), }; diff --git a/packages/types/src/__tests__/list-view-spec-parity.test.ts b/packages/types/src/__tests__/list-view-spec-parity.test.ts index 5a1cce431..faa5904eb 100644 --- a/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -31,7 +31,14 @@ * extension (add it to SANCTIONED_LOCAL with a rationale). See #2231. */ import { describe, it, expect } from 'vitest'; -import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { + ListViewSchema as SpecListViewSchema, + KanbanConfigSchema as SpecKanbanConfigSchema, + CalendarConfigSchema as SpecCalendarConfigSchema, + GanttConfigSchema as SpecGanttConfigSchema, + GalleryConfigSchema as SpecGalleryConfigSchema, + TimelineConfigSchema as SpecTimelineConfigSchema, +} from '@objectstack/spec/ui'; import { ListViewSchema as OuiListViewSchema } from '../zod/objectql.zod.js'; import { BaseSchema } from '../zod/base.zod.js'; @@ -39,6 +46,15 @@ const specShape = (SpecListViewSchema as unknown as { shape: Record }).shape; const baseShape = (BaseSchema as unknown as { shape: Record }).shape; +/** Peel `.optional()` / `.default()` wrappers off a field and return its object shape. */ +function unwrapObjectShape(schema: unknown): Record { + let cur = schema as { unwrap?: () => unknown; shape?: Record }; + for (let i = 0; i < 5 && cur && !cur.shape && typeof cur.unwrap === 'function'; i++) { + cur = cur.unwrap() as typeof cur; + } + return cur?.shape ?? {}; +} + const specKeys = Object.keys(specShape); const ouiKeys = new Set(Object.keys(ouiShape)); // Component-envelope keys owned by BaseSchema (id, className, visible, etc.) — derived, so @@ -141,3 +157,75 @@ describe('ListView spec parity (#2231 drift guard)', () => { expect(canonical.success).toBe(true); }); }); + +/** + * Per-view-type configs — derived from the spec configs, not forked (#2231). + * + * These used to be hand-written objects with their own vocabulary + * (`groupField`/`cardFields`/`imageField`/`dateField`), which is how a + * spec-authored `kanban: { groupByField }` — exactly what `CreateViewDialog` + * emits — could pass the ListView capability gate and still render the wrong + * lanes. They now carry the spec's field set; only the keys asserted below are + * local, and each is either a deprecated alias or has no spec counterpart. + */ +describe('per-view-type configs derive from the spec', () => { + const CONFIGS = { + kanban: { spec: SpecKanbanConfigSchema, local: ['groupField', 'cardFields'] }, + calendar: { spec: SpecCalendarConfigSchema, local: ['defaultView'] }, + gantt: { spec: SpecGanttConfigSchema, local: [] }, + gallery: { spec: SpecGalleryConfigSchema, local: ['imageField'] }, + timeline: { spec: SpecTimelineConfigSchema, local: ['dateField'] }, + } as const; + + it.each(Object.keys(CONFIGS))('%s carries every spec field', (key) => { + const { spec } = CONFIGS[key as keyof typeof CONFIGS]; + const specKeys = Object.keys((spec as unknown as { shape: Record }).shape); + const ouiKeys = new Set(Object.keys(unwrapObjectShape(ouiShape[key]))); + expect(specKeys.filter((k) => !ouiKeys.has(k))).toEqual([]); + }); + + it.each(Object.keys(CONFIGS))('%s declares only the sanctioned local keys', (key) => { + const { spec, local } = CONFIGS[key as keyof typeof CONFIGS]; + const specKeys = new Set(Object.keys((spec as unknown as { shape: Record }).shape)); + const localOnly = Object.keys(unwrapObjectShape(ouiShape[key])).filter((k) => !specKeys.has(k)); + expect(localOnly.sort()).toEqual([...local].sort()); + }); + + it('accepts spec vocabulary on every config', () => { + const result = OuiListViewSchema.safeParse({ + type: 'list-view', + objectName: 'accounts', + kanban: { groupByField: 'stage', columns: ['name', 'amount'] }, + calendar: { startDateField: 'starts_at', titleField: 'name', colorField: 'stage' }, + gantt: { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name', groupByField: 'owner', resourceView: true }, + gallery: { coverField: 'logo', coverFit: 'contain', cardSize: 'large', visibleFields: ['name'] }, + timeline: { startDateField: 'starts_at', titleField: 'name', scale: 'month', groupByField: 'owner' }, + }); + expect(result.success).toBe(true); + // Spec fields must survive the parse, not be silently stripped. + expect(result.success && result.data.gantt).toMatchObject({ groupByField: 'owner', resourceView: true }); + expect(result.success && result.data.timeline).toMatchObject({ scale: 'month' }); + }); + + it('still accepts the deprecated pre-#2231 aliases so stored views keep validating', () => { + const result = OuiListViewSchema.safeParse({ + type: 'list-view', + objectName: 'accounts', + kanban: { groupField: 'stage', cardFields: ['name'] }, + gallery: { imageField: 'logo' }, + timeline: { dateField: 'due_date' }, + calendar: { startDateField: 'starts_at', defaultView: 'week' }, + }); + expect(result.success).toBe(true); + }); + + it('does not require the spec-required sub-fields the product authors partially', () => { + // CreateViewDialog emits `kanban: { groupByField }` with no `columns`; spec + // marks `columns` required, so the derivation must stay `.partial()`. + expect(OuiListViewSchema.safeParse({ + type: 'list-view', + objectName: 'accounts', + kanban: { groupByField: 'stage' }, + }).success).toBe(true); + }); +}); diff --git a/packages/types/src/designer.ts b/packages/types/src/designer.ts index 516e639cd..ee7b365ac 100644 --- a/packages/types/src/designer.ts +++ b/packages/types/src/designer.ts @@ -483,7 +483,6 @@ export interface UnifiedViewConfig { gallery?: { imageField?: string; titleField?: string; - subtitleField?: string; }; /** Timeline-specific options */ timeline?: { diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index e8b206c58..37bfdac8d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -88,8 +88,6 @@ import type { export type ListViewGalleryConfig = GalleryConfig & { /** Legacy: image field (deprecated, use coverField) */ imageField?: string; - /** Legacy: subtitle field */ - subtitleField?: string; [key: string]: any; }; diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 5b5899fad..5f8878278 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -25,6 +25,26 @@ is `z.infer & ListViewRuntimeProps`. A drift-guard test (`__tests__/list-view-spec-parity.test.ts`) fails if the spec grows a field objectui hasn't triaged. **Do not hand-add spec-owned fields here** — import them from the spec. +### Per-view-type configs derive from the spec — #2231 + +`kanban` / `calendar` / `gantt` / `gallery` / `timeline` on `ListViewSchema` are the +spec's config schemas `.partial()`-ed (the product authors partial configs, and spec +marks `columns` / `titleField` / `startDateField` required). `gantt` has no local +schema at all — it flows in with the rest of `SpecListViewFields`. + +Only these keys are local, and each is asserted in the drift guard: + +| config | local key | why | +| :--- | :--- | :--- | +| `kanban` | `groupField`, `cardFields` | deprecated aliases for spec `groupByField` / `columns` | +| `calendar` | `defaultView` | no spec counterpart — promote it rather than growing this | +| `gallery` | `imageField` | deprecated alias for spec `coverField` | +| `timeline` | `dateField` | deprecated alias for spec `startDateField` | + +**The spec key is canonical and wins at every read-site.** The aliases exist so stored +view metadata keeps validating; don't author new metadata with them, and don't add a +new alias here — rename at the producer instead. + ### Spec sub-schemas are re-exported by reference (not mirrored) — #2231 The schemas that used to be hand-written "mirrors" of `@objectstack/spec/ui` are now the diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 5c00e1e35..f46a38680 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -19,6 +19,10 @@ import { z } from 'zod'; import { ListViewSchema as SpecListViewSchema, + KanbanConfigSchema as SpecKanbanConfigSchema, + CalendarConfigSchema as SpecCalendarConfigSchema, + GalleryConfigSchema as SpecGalleryConfigSchema, + TimelineConfigSchema as SpecTimelineConfigSchema, HttpMethodSchema as SpecHttpMethodSchema, HttpRequestSchema as SpecHttpRequestSchema, ViewDataSchema as SpecViewDataSchema, @@ -278,11 +282,14 @@ const UserFiltersSchema = z.object({ * - legacy vocabulary kept for back-compat: `viewType` (renamed spec `type`), * `fields`/`columns`, `filters`, the `show*` toolbar flags, `densityMode`, `color`, …; * - configs whose objectui shape is intentionally broader than spec's (migration - * deferred): `userFilters`, `sharing`, `aria`, `conditionalFormatting`, - * `exportOptions`, and the per-view-type `kanban`/`calendar`/`gantt`/`gallery`/`timeline`. + * deferred): `userFilters`, `sharing`, `aria`, `conditionalFormatting`, `exportOptions`. * - * Migrating the legacy vocabulary to the spec-canonical keys (`type`/`columns`/`filter`/ - * `userActions`) and adopting spec's narrower sub-shapes is deferred — see #2231. + * The per-view-type configs (`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) are no + * longer forks: they derive from the spec configs below, keeping only `calendar.defaultView` + * (no spec counterpart) and four deprecated aliases for the pre-#2231 vocabulary. + * + * Migrating the remaining legacy vocabulary to the spec-canonical keys (`type`/`columns`/ + * `filter`/`userActions`) is deferred — see #2231. */ // Spec view-config fields, minus: the component envelope (name/label/description → // BaseSchema), the discriminator/renamed/relaxed keys (type/columns), and the configs @@ -302,12 +309,54 @@ const SpecListViewFields = SpecListViewSchema exportOptions: true, kanban: true, calendar: true, - gantt: true, gallery: true, timeline: true, }) .partial(); +// ── Per-view-type configs, derived from spec (issue #2231) ──────────────────── +// Each is the spec config `.partial()`-ed: spec requires `columns`/`titleField`/ +// `startDateField` on some of these, but objectui authors partial configs (the +// product's own CreateViewDialog emits `kanban: { groupByField }` alone), so +// requiring them would reject views the app itself creates. `.partial()` keeps the +// spec's field set and types by reference while staying permissive — the same +// trade-off `SpecListViewFields` makes above. +// +// `gantt` needs no local schema at all: the spec config already covers every field +// the renderer reads and is `.passthrough()` for renderer-ahead knobs, so it flows +// in with the rest of `SpecListViewFields`. +// +// The deprecated aliases below are the pre-#2231 objectui vocabulary. They stay +// accepted so stored view metadata keeps validating, but the spec key is canonical +// and wins at every read-site. +// +// `.passthrough()` is kept from the pre-#2231 shapes for the same reason the spec +// puts it on `GanttConfigSchema`/`TreeConfigSchema`: the renderers grow config knobs +// ahead of the protocol (calendar's `allDayField`, for one), and stripping them here +// would silently disable a shipped capability. +const KanbanConfig = SpecKanbanConfigSchema.partial().extend({ + /** @deprecated legacy alias for the spec's `groupByField` */ + groupField: z.string().optional().describe('Deprecated alias for groupByField'), + /** @deprecated legacy alias for the spec's `columns` (fields shown on each card) */ + cardFields: z.array(z.string()).optional().describe('Deprecated alias for columns'), +}).passthrough(); + +const CalendarConfig = SpecCalendarConfigSchema.partial().extend({ + // objectui-only: the calendar renderer's initial view mode. No spec counterpart — + // promote it rather than growing this extension. + defaultView: z.enum(['month', 'week', 'day', 'agenda']).optional().describe('Initial calendar view mode'), +}).passthrough(); + +const GalleryConfig = SpecGalleryConfigSchema.partial().extend({ + /** @deprecated legacy alias for the spec's `coverField` */ + imageField: z.string().optional().describe('Deprecated alias for coverField'), +}).passthrough(); + +const TimelineConfig = SpecTimelineConfigSchema.partial().extend({ + /** @deprecated legacy alias for the spec's `startDateField` */ + dateField: z.string().optional().describe('Deprecated alias for startDateField'), +}).passthrough(); + // View-kind enum reused from spec (unwrap its `.default('grid')`) so it cannot drift. const ViewKindEnum = SpecListViewSchema.shape.type.removeDefault(); @@ -396,36 +445,12 @@ export const ListViewSchema = BaseSchema fileNamePrefix: z.string().optional(), }), ]).optional().describe('Export options'), - kanban: z.object({ - groupField: z.string(), - titleField: z.string().optional(), - cardFields: z.array(z.string()).optional(), - }).passthrough().optional().describe('Kanban-specific configuration'), - calendar: z.object({ - startDateField: z.string(), - endDateField: z.string().optional(), - titleField: z.string().optional(), - defaultView: z.enum(['month', 'week', 'day', 'agenda']).optional(), - }).passthrough().optional().describe('Calendar-specific configuration'), - gantt: z.object({ - startDateField: z.string(), - endDateField: z.string(), - titleField: z.string().optional(), - progressField: z.string().optional(), - dependenciesField: z.string().optional(), - }).passthrough().optional().describe('Gantt-specific configuration'), - gallery: z.object({ - coverField: z.string().optional(), - titleField: z.string().optional(), - imageField: z.string().optional(), - subtitleField: z.string().optional(), - }).passthrough().optional().describe('Gallery-specific configuration'), - timeline: z.object({ - startDateField: z.string().optional(), - endDateField: z.string().optional(), - titleField: z.string().optional(), - dateField: z.string().optional(), - }).passthrough().optional().describe('Timeline-specific configuration'), + // Per-view-type configs — spec-derived (see the definitions above #2231). + // `gantt` is NOT here: it flows in from `SpecListViewFields` unmodified. + kanban: KanbanConfig.optional().describe('Kanban-specific configuration'), + calendar: CalendarConfig.optional().describe('Calendar-specific configuration'), + gallery: GalleryConfig.optional().describe('Gallery-specific configuration'), + timeline: TimelineConfig.optional().describe('Timeline-specific configuration'), }); /**