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: 35 additions & 0 deletions .changeset/shaggy-doors-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@object-ui/types": minor
"@object-ui/core": minor
"@object-ui/plugin-list": minor
"@object-ui/plugin-view": minor
"@object-ui/app-shell": minor
---

Derive `ViewType` from `@objectstack/spec` instead of re-declaring it

`ViewType` and its zod face `ViewTypeSchema` were hand-written eleven-arm copies of
the spec's list-view type vocabulary. `@objectstack/spec@17.3.0` added `page` and
neither followed, so every structure keyed on them stayed total over the copy and
compiled green while being incomplete — including the one whose doc comment promises
that "a kind added to the union fails the build HERE". A spec-valid `type: 'page'`
view was left unresolved and fell back to a grid with no error, no warning and no
console line, while the published validator accepted it.

Both faces now derive from `@objectstack/spec/ui` `ListView['type']`, and the
renderer-side structures derive from that in turn:

- `@object-ui/core` exports `ListViewVisualization` and `isListViewVisualization` —
the visualizations `ListView` draws, which deliberately exclude `page` for the same
reason the spec's own `VisualizationType` does (a `type: 'page'` view mounts a
published page through `pageName` rather than drawing records).
- A spec-valid but undrawable kind still falls back to a grid, but now warns once
instead of degrading identically to a typo.

Widened surfaces: `ViewType` / `ViewTypeSchema` gain `page`; `UnifiedViewType` gains
`tree` (it had drifted); the `list-view` SDUI registry offers `chart` and `tree`,
which the renderer has drawn for releases.

⚠️ Consumers holding an exhaustive `switch` or a total `Record<ViewType, …>` will
now fail to compile until they account for `page`. That failure is the point of the
change — it is the guarantee that was silently lost.
50 changes: 38 additions & 12 deletions packages/app-shell/src/views/CreateViewDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
cn,
} from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import type { ListViewVisualization } from '@object-ui/core';
import { slugify } from './metadata-admin/createDerive.js';
import {
deriveFieldOptions,
Expand Down Expand Up @@ -77,24 +78,49 @@ export interface CreateViewDialogProps {
}

interface ViewTypeMeta {
type: string;
type: ListViewVisualization;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
}

/**
* Build the picker's rows from a TOTAL record over the visualizations.
*
* A total `Record<ListViewVisualization, …>` (objectui#8127). This picker was a
* nine-entry array whose `type` was plain `string`, so it was never compared
* against any view-type union — one of the sites the card records as "drifted,
* with no type-checking at all". Keyed on the derived visualization union it now
* fails the build when the spec adds a visualization, instead of quietly
* becoming unofferable.
*
* ⚠️ `page` is absent BY CONSTRUCTION and needs no locale keys. The spec models
* `type: 'page'` as a list view that mounts a published page through `pageName`,
* not as a visualization with field bindings — and this dialog's whole job below
* is collecting those bindings. Offering it would require a page picker and a
* `pageName` binding this dialog has no notion of.
*
* ⚠️ Every bundle key stays spelled out as a LITERAL argument to `t()`, never
* assembled from a stem. `scripts/check-i18n-call-site-keys.mjs` can only see a
* member missing from all ten packs when the key is literal; a
* `t(`console.objectView.viewType${stem}`)` template turns ten packs' worth of
* coverage into a prefix check, which is the same "guard that stopped guarding"
* shape this card exists to remove.
*/
function buildViewTypeMeta(t: (k: string) => string): ViewTypeMeta[] {
return [
{ type: 'grid', icon: LayoutGrid, label: t('console.objectView.viewTypeGrid'), description: t('console.objectView.viewTypeGridDesc') },
{ type: 'kanban', icon: KanbanSquare, label: t('console.objectView.viewTypeKanban'), description: t('console.objectView.viewTypeKanbanDesc') },
{ type: 'calendar', icon: CalendarIcon, label: t('console.objectView.viewTypeCalendar'), description: t('console.objectView.viewTypeCalendarDesc') },
{ type: 'gallery', icon: ImageIcon, label: t('console.objectView.viewTypeGallery'), description: t('console.objectView.viewTypeGalleryDesc') },
{ type: 'timeline', icon: Clock, label: t('console.objectView.viewTypeTimeline'), description: t('console.objectView.viewTypeTimelineDesc') },
{ type: 'gantt', icon: GanttChartSquare, label: t('console.objectView.viewTypeGantt'), description: t('console.objectView.viewTypeGanttDesc') },
{ type: 'map', icon: MapIcon, label: t('console.objectView.viewTypeMap'), description: t('console.objectView.viewTypeMapDesc') },
{ type: 'chart', icon: BarChart3, label: t('console.objectView.viewTypeChart'), description: t('console.objectView.viewTypeChartDesc') },
{ type: 'tree', icon: ListTree, label: t('console.objectView.viewTypeTree'), description: t('console.objectView.viewTypeTreeDesc') },
];
const meta: Record<ListViewVisualization, Omit<ViewTypeMeta, 'type'>> = {
grid: { icon: LayoutGrid, label: t('console.objectView.viewTypeGrid'), description: t('console.objectView.viewTypeGridDesc') },
kanban: { icon: KanbanSquare, label: t('console.objectView.viewTypeKanban'), description: t('console.objectView.viewTypeKanbanDesc') },
calendar: { icon: CalendarIcon, label: t('console.objectView.viewTypeCalendar'), description: t('console.objectView.viewTypeCalendarDesc') },
gallery: { icon: ImageIcon, label: t('console.objectView.viewTypeGallery'), description: t('console.objectView.viewTypeGalleryDesc') },
timeline: { icon: Clock, label: t('console.objectView.viewTypeTimeline'), description: t('console.objectView.viewTypeTimelineDesc') },
gantt: { icon: GanttChartSquare, label: t('console.objectView.viewTypeGantt'), description: t('console.objectView.viewTypeGanttDesc') },
map: { icon: MapIcon, label: t('console.objectView.viewTypeMap'), description: t('console.objectView.viewTypeMapDesc') },
chart: { icon: BarChart3, label: t('console.objectView.viewTypeChart'), description: t('console.objectView.viewTypeChartDesc') },
tree: { icon: ListTree, label: t('console.objectView.viewTypeTree'), description: t('console.objectView.viewTypeTreeDesc') },
};
return (Object.entries(meta) as [ListViewVisualization, Omit<ViewTypeMeta, 'type'>][])
.map(([type, row]) => ({ type, ...row }));
}

/** Suggest a non-colliding default name like "Grid 1", "Grid 2", … */
Expand Down
80 changes: 79 additions & 1 deletion packages/core/src/utils/__tests__/normalize-list-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ViewTypeSchema } from '@object-ui/types/zod';
import {
normalizeListViewSchema,
rowHeightToDensityMode,
isListViewVisualization,
DENSITY_MODE_TO_ROW_HEIGHT,
ROW_HEIGHT_TO_DENSITY_MODE,
} from '../normalize-list-view.js';
Expand Down Expand Up @@ -611,3 +613,79 @@ describe('rowHeightToDensityMode (#4440)', () => {
});
});
});


/**
* objectui#8127 — a spec-valid `type: 'page'` list view silently rendered as a
* grid, because `ViewType` was a hand-written copy of the spec's list and every
* structure keyed on it was total over the COPY.
*
* These pin the two halves of the fix: the derived vocabulary ACCEPTS `page`,
* and the renderer's drawable set still refuses it — loudly, not silently.
*/
describe('the derived view-type vocabulary (objectui#8127)', () => {
it('accepts the spec`s `page` list-view type, and refuses a typo', () => {
// The published validator's answer. Before the derivation, `ViewTypeSchema`
// was an eleven-arm hand copy that refused `page` while `ViewKindEnum`
// (already derived) accepted it — two faces of one vocabulary disagreeing.
expect(ViewTypeSchema.safeParse('page').success).toBe(true);
// Control: the vocabulary is still closed, so the `true` above is a reading
// and not an enum that accepts anything.
expect(ViewTypeSchema.safeParse('nonsense-control').success).toBe(false);
});

it('keeps the two objectui view CATEGORIES, which have no spec counterpart', () => {
expect(ViewTypeSchema.safeParse('list').success).toBe(true);
expect(ViewTypeSchema.safeParse('detail').success).toBe(true);
});

it('does not count `page` among the visualizations ListView draws', () => {
// The spec models the two separately: `AppearanceConfig.allowedVisualizations`
// is typed on `VisualizationType`, which has no `page`, because a
// `type: 'page'` view mounts a published page through `pageName` rather
// than drawing records.
expect(isListViewVisualization('page')).toBe(false);
for (const kind of ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree']) {
expect(isListViewVisualization(kind)).toBe(true);
}
});

it('does not answer for an inherited Object.prototype key', () => {
expect(isListViewVisualization('toString')).toBe(false);
expect(isListViewVisualization('constructor')).toBe(false);
});
});

describe('the undrawable-kind fallback is loud (objectui#8127)', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('warns once for `page` and still falls back to the caller`s grid default', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const out = normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'page' });

// The fallback itself is UNCHANGED — this card does not add a page renderer.
expect((out as { viewType?: string }).viewType).toBe('grid');

// What changed is that it says so. Before this, `page` and a typo produced
// byte-identical output and silence.
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('page');

// Once per process, not once per render: this runs inside a normalizer a
// render can call on every keystroke.
normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'page' });
expect(warn).toHaveBeenCalledTimes(1);
});

it('stays silent for the objectui view categories and for a plain typo', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
for (const kind of ['list', 'detail', 'nonsense-control']) {
normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: kind });
}
// `list` folds to grid by design and `detail` is another renderer entirely;
// a typo is the caller's own problem and already degrades honestly.
expect(warn).not.toHaveBeenCalled();
});
});
110 changes: 101 additions & 9 deletions packages/core/src/utils/normalize-list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,43 @@ const ARIA_KEY_ALIASES: Record<string, string> = {
};

/**
* The view kinds `ListView` actually draws — one key per `case` in its
* A visualization `ListView` actually draws — one member per `case` in its
* `viewComponentSchema` switch (`packages/plugin-list/src/ListView.tsx`).
*
* A total `Record<…, true>` over the shared {@link ViewType} union minus the
* two members that are not a LIST visualization, for the same reason the
* density maps above are `Record<RowHeight, …>`: a kind added to the union
* fails the build HERE instead of silently staying unreadable authored input.
* - `list` is the view CATEGORY, not a kind — it already folds to `grid`.
* - `detail` is a different renderer (`plugin-detail`), never a ListView case.
* DERIVED from {@link ViewType} (objectui#8127), which is itself derived from
* `@objectstack/spec/ui` `ListView['type']`. Spelled as an `Exclude` of the
* members this renderer does not draw rather than as the spec's own
* `VisualizationType`, deliberately: `Exclude` leaves the SPEC's list as the
* thing being subtracted from, so ANY member the spec adds — visualization or
* not — lands here as a required key and fails the build, which is what the
* promise below actually says. Keying on `VisualizationType` instead would let
* a future non-visualization list type (another `page`) pass this site in
* silence, which is the exact failure being fixed.
*
* It happens to equal `@objectstack/spec/ui`'s `VisualizationType` today. That
* is a fact about 17.3.0, not the definition.
*/
export type ListViewVisualization = Exclude<ViewType, 'list' | 'detail' | 'page'>;

/**
* The view kinds `ListView` actually draws.
*
* A total `Record<…, true>` over {@link ListViewVisualization}, for the same
* reason the density maps above are `Record<RowHeight, …>`: a kind added to the
* union fails the build HERE instead of silently staying unreadable authored
* input.
*
* ⚠️ That promise was FALSE between `@objectstack/spec@17.3.0` and objectui#8127.
* `ViewType` was a hand-written copy of the spec's list, so this map was total
* over the copy and compiled green while the spec's `page` had nowhere to land.
* Both faces are derived now, and the two totality structures below close the
* other half of the union, so every member of the spec's list is accounted for
* by a structure that fails the build rather than by a reader's memory.
*
* `hasOwnProperty`, not `in` — same trap as {@link rowHeightToDensityMode}:
* `in` walks the prototype chain, so `'toString'` would read as a view kind.
*/
const LIST_VIEW_KINDS: Record<Exclude<ViewType, 'list' | 'detail'>, true> = {
const LIST_VIEW_KINDS: Record<ListViewVisualization, true> = {
grid: true,
kanban: true,
gallery: true,
Expand All @@ -154,16 +177,85 @@ const LIST_VIEW_KINDS: Record<Exclude<ViewType, 'list' | 'detail'>, true> = {
tree: true,
};

/**
* Every member of {@link ViewType} that {@link LIST_VIEW_KINDS} does NOT carry,
* with the reason — and, where the answer is "the author authored something
* valid and will get a grid", the sentence to say out loud.
*
* Total over `Exclude<ViewType, ListViewVisualization>` so the two records
* together partition the derived union: a member the spec adds must be classed
* as drawable or explained HERE, and until it is, neither record compiles.
*
* `null` means silence is correct — these are objectui view CATEGORIES, never
* authored as a list-view kind:
* - `list` is the view CATEGORY, not a kind — it already folds to `grid`.
* - `detail` is a different renderer (`plugin-detail`), never a ListView case.
*/
const UNDRAWABLE_VIEW_KINDS: Record<Exclude<ViewType, ListViewVisualization>, string | null> = {
list: null,
detail: null,
page: 'it mounts a published page (bound through `pageName`) in place of rows, which this renderer has no branch for',
};

/** One warning per undrawable kind per process — see {@link warnUndrawableViewKind}. */
const warnedUndrawableKinds = new Set<string>();

/**
* Fail-open is **loud**, not silent — the convention this repo already applies
* to unevaluable predicates (`../evaluator/fieldRules.ts`, objectstack#5149).
*
* The bug objectui#8127 records is not only that `type: 'page'` degrades to a
* grid; it is that it degrades IDENTICALLY to a typo. Measured on `5505aec`,
* `specType: 'page'` and `specType: 'nonsense'` both left `viewType: 'grid'`
* with no error, no warning and no console line — and `@object-ui/types`'
* published validator ACCEPTS `viewType: 'page'` while refusing the typo. An
* author who writes a spec-valid view and is silently handed a different one
* has no way to tell that from a working grid, so the degrade says so once.
*
* Once per kind per process, and never for the two objectui categories: this
* runs inside a normalizer that a render can call on every keystroke, and the
* point is one line, not a scrolling wall.
*/
function warnUndrawableViewKind(kind: string): void {
const reason = Object.prototype.hasOwnProperty.call(UNDRAWABLE_VIEW_KINDS, kind)
? UNDRAWABLE_VIEW_KINDS[kind as keyof typeof UNDRAWABLE_VIEW_KINDS]
: null;
if (reason === null || warnedUndrawableKinds.has(kind)) return;
warnedUndrawableKinds.add(kind);
console.warn(
`[object-ui] This build accepts a ${JSON.stringify(kind)} list view but cannot draw one: ` +
`${reason}. The view falls back to a grid. ` +
'Author a visualization this renderer draws, or render the page through the surface that owns it.',
);
}

/**
* Whether `value` names a visualization `ListView` draws.
*
* Exported so a read site cannot restate the membership question as its own
* literal array: the gate and the seam must answer one question, which is the
* rule this file's own `availableViews` notes already argue for (objectui#5042,
* objectui#7544, and now objectui#8127).
*/
export function isListViewVisualization(value: unknown): value is ListViewVisualization {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(LIST_VIEW_KINDS, value);
}

/**
* The author's view kind, or `undefined` when the value names no kind ListView
* draws. An unrecognized kind is deliberately left unresolved rather than
* written through to `viewType`: the caller's own `'grid'` default is a more
* honest answer than a `viewType` no branch matches (the same call
* `normalizeChartSchema` makes for a chart family it cannot draw).
*
* A kind the SPEC allows but this renderer cannot draw takes the same fallback
* and additionally warns — see {@link warnUndrawableViewKind}.
*/
function readListViewKind(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
return Object.prototype.hasOwnProperty.call(LIST_VIEW_KINDS, value) ? value : undefined;
if (isListViewVisualization(value)) return value;
warnUndrawableViewKind(value);
return undefined;
}

/**
Expand Down
Loading
Loading