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
40 changes: 40 additions & 0 deletions .changeset/spec-view-configs.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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' });
});
});

Expand Down
9 changes: 5 additions & 4 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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
Expand Down
57 changes: 35 additions & 22 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -779,12 +779,16 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
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 : []),
Expand Down Expand Up @@ -994,12 +998,16 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
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 : []),
Expand Down Expand Up @@ -1338,28 +1346,34 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...((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<string, any>;
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',
Expand Down Expand Up @@ -1390,7 +1404,6 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// 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 } : {}),
};
}
Expand Down
1 change: 0 additions & 1 deletion packages/plugin-list/src/ObjectGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export interface ObjectGalleryProps {
imageField?: string;
/** @deprecated Use gallery.titleField instead */
titleField?: string;
subtitleField?: string;
};
data?: Record<string, unknown>[];
dataSource?: { find: (name: string, query: unknown) => Promise<unknown> };
Expand Down
127 changes: 127 additions & 0 deletions packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ComponentRegistry.get>;
let kanbanCalls: any[];

beforeAll(() => {
prevObjectKanban = ComponentRegistry.get('object-kanban');
ComponentRegistry.register('object-kanban', (props: any) => {
kanbanCalls.push(props);
return <div data-testid="kanban-stub" />;
});
});
afterAll(() => {
if (prevObjectKanban) {
ComponentRegistry.register('object-kanban', prevObjectKanban);
} else {
ComponentRegistry.unregister('object-kanban');
}
});
beforeEach(() => {
kanbanCalls = [];
mockDataSource.find.mockClear();
});

const kanbanSchema = (kanban: Record<string, unknown>) => ({
type: 'list-view',
objectName: 'contacts',
viewType: 'kanban',
fields: ['name'],
kanban,
} as unknown as ListViewSchema);

it('groups by the spec `groupByField`', async () => {
renderWithProvider(<ListView schema={kanbanSchema({ groupByField: 'stage' })} dataSource={mockDataSource} />);
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(<ListView schema={kanbanSchema({ groupField: 'priority' })} dataSource={mockDataSource} />);
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(
<ListView schema={kanbanSchema({ groupByField: 'stage', groupField: 'legacy' })} dataSource={mockDataSource} />,
);
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(
<ListView
schema={kanbanSchema({ groupByField: 'stage', columns: ['name', 'amount'] })}
dataSource={mockDataSource}
/>,
);
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(
<ListView schema={kanbanSchema({ groupByField: 'stage', summarizeField: 'amount' })} dataSource={mockDataSource} />,
);
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(<ListView schema={schema} dataSource={mockDataSource} />);

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);
}
});
});
8 changes: 5 additions & 3 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -766,16 +766,18 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
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 || {}),
};
Expand Down
Loading
Loading