Skip to content

Commit 265d762

Browse files
os-zhuangclaude
andcommitted
fix(spec-parity): the fifteen Tier-2 spec values render instead of validating into nothing (#2942)
Every row below is the #2897 shape — validates at authoring time, renders nothing (or a dead control), no test fails and no warning fires: - UserFilters `element: 'toggle'`: `default: return null` deleted the ENTIRE filter bar for stored toggle configs. The existing-but-unreachable ToggleFilters branch is wired; authoring stays untypeable (ADR-0053) while stored metadata keeps rendering (spec ADR-0047 §3.4a). - UserFilters `date-range` / `text`: dead "No options" popovers become a from/to date pair (emits >=/<= bounds) and a contains search input. - useAnimation: preset/easing maps re-keyed to the spec's underscore vocabulary (+ rotate/flip via usePageTransition's classes); hyphen dialect and `scale-fade` stay accepted for stored configs. The `EASING_MAP[easing] || easing` fallthrough no longer emits invalid CSS. - NotificationContext: displayType materialized (spec default toast, legacy `modal` presents as alert) and the unions now match NotificationTypeSchema / NotificationPositionSchema instead of claiming to. - useNavigationOverlay: the spec `size` buckets resolve to viewport-clamped widths off app-shell too (explicit `width` still wins; `auto` stays host-derived). - Both ThemeProviders: `mode: 'auto'` follows the OS instead of adding a dead `auto` class that locked the light theme; `system` kept as the pre-spec spelling. - AdvancedChart: the single-value families (gauge/solid-gauge/metric/kpi/ bullet) render the measure as a number, table/pivot name their owning component, unknown types are named — never the bar SHELL with null series marks that was indistinguishable from an empty dataset (reachable via ChartRenderer's `schema.chartType ?? spec.chartType` bypass). - Export menus (ObjectGrid + ListView): undeliverable formats render disabled with a reason — picking PDF used to close the popover and download nothing; xlsx did the same without the server path. - Timeline: the spec `scale` key is read at last (legacy `timeScale` kept); hour/quarter/year generate real gantt header buckets instead of a blank axis. - Toaster: position (all six spec values) and `limit` reach sonner instead of being discarded by a bare `<SonnerToaster />`. - useSpecGesture: the DECLARED `config.type` drives recognition — pan/drag/rotate/double_tap no longer collapse to tap; useGesture gains real double-tap (two taps, not one) and two-touch pinch/rotate deltas. - ReportViewer: `aggregation: 'distinct'` computes a distinct count instead of a blank summary cell. - FieldEditWidget: inline resolution goes through the form's alias table, so `json` gets the code editor, `tree` the lookup picker, and composite/ record/repeater/video/audio/autonumber resolve to their documented exclusions; ObjectGrid's editability gate consults the same contract, so a `composite` cell is read-only instead of a value-corrupting text box. - FilterBuilder: $startsWith/$endsWith/$null/$exists become authorable (startsWith/endsWith/isNull/isNotNull/exists/notExists) and round-trip through condToMongo/kvToCondition — every FieldOperatorsSchema token is now reachable from the UI. Each fix lands with a spec-parity guard per the #2897 template; `fields`, `mobile`, `plugin-charts` and `providers` gain the `@objectstack/spec` devDependency that makes those guards possible (no second zod peer variant materialized — verified in the lockfile). Refs #2942, #2901 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f59f2c1 commit 265d762

41 files changed

Lines changed: 1800 additions & 297 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app-shell/src/chrome/ThemeProvider.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createContext, useContext, useEffect, useState } from "react"
22

3-
type Theme = "dark" | "light" | "system"
3+
// `auto` is the spec's OS-following mode (ThemeModeSchema, ui/theme.zod.ts);
4+
// `system` is this provider's pre-spec spelling, kept for stored values.
5+
type Theme = "dark" | "light" | "auto" | "system"
46

57
type ThemeProviderProps = {
68
children: React.ReactNode
@@ -35,7 +37,10 @@ export function ThemeProvider({
3537

3638
root.classList.remove("light", "dark")
3739

38-
if (theme === "system") {
40+
// Branching on `system` alone sent the spec's `auto` into
41+
// `classList.add('auto')` — a class no Tailwind variant matches, locking
42+
// the light theme with the OS preference ignored (#2942).
43+
if (theme === "auto" || theme === "system") {
3944
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
4045
.matches
4146
? "dark"
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Toaster position ↔ spec vocabulary parity + behavior (#2942).
11+
*
12+
* The `toaster` block used to mount `<SonnerToaster />` bare with
13+
* `inputs: []` — all six `NotificationPositionSchema` values (and `limit`)
14+
* validated and were discarded.
15+
*/
16+
import { describe, it, expect, vi, beforeAll } from 'vitest';
17+
import React from 'react';
18+
import '@testing-library/jest-dom';
19+
import { NotificationPositionSchema } from '@objectstack/spec/ui';
20+
import { renderComponent } from './test-utils';
21+
import { TOASTER_POSITIONS } from '../renderers/feedback/toaster';
22+
23+
// Sonner defers mounting its DOM container, so the behavior tests assert on
24+
// the props our registration hands it rather than sonner internals.
25+
vi.mock('../ui/sonner', () => ({
26+
Toaster: (props: { position?: string; visibleToasts?: number }) => (
27+
<div data-testid="sonner-mock" data-position={props.position} data-limit={props.visibleToasts} />
28+
),
29+
toast: Object.assign(() => undefined, {
30+
success: () => undefined,
31+
error: () => undefined,
32+
info: () => undefined,
33+
warning: () => undefined,
34+
}),
35+
}));
36+
37+
beforeAll(async () => {
38+
await import('../renderers');
39+
}, 30000);
40+
41+
describe('toaster covers the spec notification-position vocabulary', () => {
42+
const rawOptions = (NotificationPositionSchema as unknown as { options?: readonly string[] }).options;
43+
const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : [];
44+
45+
it('reads a non-empty enum from the spec', () => {
46+
expect(specNames, 'could not read NotificationPositionSchema.options from the spec').not.toEqual([]);
47+
});
48+
49+
it('maps every spec position onto a sonner position', () => {
50+
const unmapped = specNames.filter((name) => !(name in TOASTER_POSITIONS));
51+
expect(unmapped, 'these validate and then the toaster discards them').toEqual([]);
52+
});
53+
54+
it('maps only spec positions and the ToasterSchema hyphen dialect', () => {
55+
const sonnerIds = new Set(Object.values(TOASTER_POSITIONS));
56+
const extra = Object.keys(TOASTER_POSITIONS).filter(
57+
(name) => !specNames.includes(name) && !sonnerIds.has(name as never),
58+
);
59+
expect(extra, 'unexpected renderer-local position dialect').toEqual([]);
60+
});
61+
});
62+
63+
describe('toaster behavior', () => {
64+
it('a spec underscore position (and limit) reaches sonner translated', () => {
65+
const { getByTestId } = renderComponent({ type: 'toaster', position: 'top_center', limit: 3 } as any);
66+
const toaster = getByTestId('sonner-mock');
67+
expect(toaster).toHaveAttribute('data-position', 'top-center');
68+
expect(toaster).toHaveAttribute('data-limit', '3');
69+
});
70+
71+
it('the hyphen dialect keeps working', () => {
72+
const { getByTestId } = renderComponent({ type: 'toaster', position: 'bottom-left' } as any);
73+
expect(getByTestId('sonner-mock')).toHaveAttribute('data-position', 'bottom-left');
74+
});
75+
});

packages/components/src/custom/filter-builder.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ const defaultOperators = [
7171
{ value: "between", label: "Between" },
7272
{ value: "in", label: "In" },
7373
{ value: "notIn", label: "Not in" },
74+
// String-specific spec operators ($startsWith / $endsWith) — they validated
75+
// at the data layer but were unreachable from this dropdown (#2942).
76+
{ value: "startsWith", label: "Starts with" },
77+
{ value: "endsWith", label: "Ends with" },
78+
// Null / existence spec operators ($null / $exists). Distinct from
79+
// isEmpty/isNotEmpty, which also treat '' as empty.
80+
{ value: "isNull", label: "Is null" },
81+
{ value: "isNotNull", label: "Is not null" },
82+
{ value: "exists", label: "Is set" },
83+
{ value: "notExists", label: "Is not set" },
7484
] as const
7585

7686
/**
@@ -121,16 +131,23 @@ const useSafeFilterTranslation = createSafeTranslation(
121131
'filterBuilder.operators.between': 'Between',
122132
'filterBuilder.operators.in': 'In',
123133
'filterBuilder.operators.notIn': 'Not in',
134+
'filterBuilder.operators.startsWith': 'Starts with',
135+
'filterBuilder.operators.endsWith': 'Ends with',
136+
'filterBuilder.operators.isNull': 'Is null',
137+
'filterBuilder.operators.isNotNull': 'Is not null',
138+
'filterBuilder.operators.exists': 'Is set',
139+
'filterBuilder.operators.notExists': 'Is not set',
124140
},
125141
'filterBuilder.where',
126142
)
127143

128-
const textOperators = ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"]
129-
const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty"]
144+
const NULLNESS_OPERATORS = ["isNull", "isNotNull", "exists", "notExists"]
145+
const textOperators = ["equals", "notEquals", "contains", "notContains", "startsWith", "endsWith", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
146+
const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
130147
const booleanOperators = ["equals", "notEquals"]
131-
const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty"]
132-
const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"]
133-
const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"]
148+
const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
149+
const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
150+
const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
134151

135152
/** Field types that share the same operator/input behavior as number (numeric comparison operators, number input) */
136153
const numberLikeTypes = ["number", "currency", "percent", "rating"]
@@ -256,7 +273,7 @@ function FilterBuilder({
256273
}
257274

258275
const needsValueInput = (operator: string) => {
259-
return !["isEmpty", "isNotEmpty"].includes(operator)
276+
return !["isEmpty", "isNotEmpty", "isNull", "isNotNull", "exists", "notExists"].includes(operator)
260277
}
261278

262279
const getInputType = (fieldValue: string) => {

packages/components/src/renderers/feedback/toaster.tsx

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,58 @@ import { ComponentRegistry } from '@object-ui/core';
1010
import type { ToasterSchema } from '@object-ui/types';
1111
import { Toaster as SonnerToaster } from '../../ui/sonner';
1212

13-
ComponentRegistry.register('toaster',
14-
() => {
15-
return <SonnerToaster />;
13+
/**
14+
* Spec `NotificationPositionSchema` (`ui/notification.zod.ts`, underscore
15+
* spellings) → sonner's hyphenated position ids. The hyphen spellings are
16+
* `ToasterSchema`'s own dialect and map through as identities, so both
17+
* registers work. Before #2942 the renderer mounted `<SonnerToaster />` bare
18+
* (`inputs: []`) and every authored position — all six spec values — plus
19+
* `limit` validated and changed nothing. Exported for the spec-parity test.
20+
*/
21+
export const TOASTER_POSITIONS: Record<
22+
string,
23+
'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
24+
> = {
25+
top_left: 'top-left',
26+
top_center: 'top-center',
27+
top_right: 'top-right',
28+
bottom_left: 'bottom-left',
29+
bottom_center: 'bottom-center',
30+
bottom_right: 'bottom-right',
31+
// ToasterSchema's hyphen dialect — identity entries.
32+
'top-left': 'top-left',
33+
'top-center': 'top-center',
34+
'top-right': 'top-right',
35+
'bottom-left': 'bottom-left',
36+
'bottom-center': 'bottom-center',
37+
'bottom-right': 'bottom-right',
38+
};
39+
40+
ComponentRegistry.register(
41+
'toaster',
42+
({ schema }: { schema: ToasterSchema }) => {
43+
const position = schema?.position ? TOASTER_POSITIONS[schema.position] : undefined;
44+
const limit = typeof schema?.limit === 'number' && schema.limit > 0 ? schema.limit : undefined;
45+
return (
46+
<SonnerToaster
47+
{...(position ? { position } : {})}
48+
{...(limit ? { visibleToasts: limit } : {})}
49+
/>
50+
);
1651
},
1752
{
1853
namespace: 'ui',
1954
label: 'Toaster',
20-
inputs: [],
21-
defaultProps: {}
22-
}
55+
inputs: [
56+
{
57+
name: 'position',
58+
type: 'enum',
59+
enum: ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'],
60+
defaultValue: 'bottom-right',
61+
label: 'Position',
62+
},
63+
{ name: 'limit', type: 'number', label: 'Max visible toasts' },
64+
],
65+
defaultProps: {},
66+
},
2367
);

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,6 @@ export * from './utils/chart-series.js';
5353
export * from './utils/dataset-format.js';
5454
export * from './utils/record-title.js';
5555
export * from './utils/export-filename.js';
56+
export * from './utils/export-formats.js';
5657
export * from './utils/reference-keys.js';
5758
export * from './utils/normalize-list-view.js';
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Export format ↔ spec vocabulary parity + deliverability (#2942).
11+
*
12+
* `ListViewSchema.exportOptions` accepts csv/xlsx/pdf/json; the export menus
13+
* offered every authored format and then silently no-opped on the ones no
14+
* path can produce — picking PDF closed the popover and downloaded nothing,
15+
* and XLSX did the same whenever the server path was unavailable. The menus
16+
* now consult `isExportFormatDeliverable` and render dead formats disabled.
17+
*/
18+
import { describe, it, expect } from 'vitest';
19+
import { ListViewSchema } from '@objectstack/spec/ui';
20+
import { EXPORT_FORMATS, isExportFormatDeliverable } from '../export-formats';
21+
22+
function specFormats(): string[] {
23+
const field = (ListViewSchema as unknown as { shape?: Record<string, unknown> }).shape
24+
?.exportOptions as { def?: { innerType?: { element?: { options?: readonly string[] } } } } | undefined;
25+
const options = field?.def?.innerType?.element?.options;
26+
return Array.isArray(options) ? [...options] : [];
27+
}
28+
29+
describe('export formats cover the spec vocabulary', () => {
30+
const specNames = specFormats();
31+
32+
it('reads a non-empty enum from the spec', () => {
33+
expect(specNames, 'could not read ListViewSchema.shape.exportOptions element options').not.toEqual([]);
34+
});
35+
36+
it('declares exactly the spec formats', () => {
37+
expect([...EXPORT_FORMATS].sort()).toEqual([...specNames].sort());
38+
});
39+
40+
it('every spec format has an explicit deliverability decision', () => {
41+
for (const format of specNames) {
42+
// Both wiring states must produce a boolean — never an accidental
43+
// fall-through that lets a dead option through to a silent no-op.
44+
expect(typeof isExportFormatDeliverable(format, true)).toBe('boolean');
45+
expect(typeof isExportFormatDeliverable(format, false)).toBe('boolean');
46+
}
47+
});
48+
});
49+
50+
describe('deliverability per wiring', () => {
51+
it('csv/json always deliver (client fallback)', () => {
52+
expect(isExportFormatDeliverable('csv', false)).toBe(true);
53+
expect(isExportFormatDeliverable('json', false)).toBe(true);
54+
});
55+
56+
it('xlsx delivers only over the server path', () => {
57+
expect(isExportFormatDeliverable('xlsx', true)).toBe(true);
58+
expect(isExportFormatDeliverable('xlsx', false)).toBe(false);
59+
});
60+
61+
it('pdf has no producer yet — never deliverable, never a silent no-op', () => {
62+
expect(isExportFormatDeliverable('pdf', true)).toBe(false);
63+
expect(isExportFormatDeliverable('pdf', false)).toBe(false);
64+
});
65+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The spec's export-format vocabulary (`ui/view.zod.ts` `exportOptions`).
11+
* Exported for the spec-parity test.
12+
*/
13+
export const EXPORT_FORMATS: ReadonlySet<string> = new Set(['csv', 'xlsx', 'pdf', 'json']);
14+
15+
/**
16+
* Whether the current wiring can actually PRODUCE a file for one export
17+
* format (#2942):
18+
*
19+
* - `csv` / `json` — always: the client fallback serializes them locally;
20+
* - `xlsx` — only over the server streaming path (`dataSource.exportDownload`
21+
* with a real object binding): type-aware value formatting and permission
22+
* enforcement are server-side, and there is no client XLSX writer;
23+
* - `pdf` — no producer exists on either path yet.
24+
*
25+
* The export menus used to offer every authored format unconditionally and
26+
* then silently no-op: picking PDF closed the popover and downloaded
27+
* nothing. The menus now render undeliverable formats disabled, so the
28+
* authored option is visible but cannot dead-click.
29+
*/
30+
export function isExportFormatDeliverable(format: string, serverExportAvailable: boolean): boolean {
31+
switch (format) {
32+
case 'csv':
33+
case 'json':
34+
return true;
35+
case 'xlsx':
36+
return serverExportAvailable;
37+
case 'pdf':
38+
default:
39+
return false;
40+
}
41+
}

packages/fields/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"react-dom": "^18.0.0 || ^19.0.0"
5050
},
5151
"devDependencies": {
52+
"@objectstack/spec": "^17.0.0-rc.0",
5253
"@types/react": "19.2.17",
5354
"@types/react-dom": "19.2.3",
5455
"@vitejs/plugin-react": "^6.0.4",

packages/fields/src/FieldEditWidget.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
*/
88

99
import { describe, it, expect } from 'vitest';
10+
import { FieldType } from '@objectstack/spec/data';
1011
import {
1112
FORM_FIELD_TYPES,
1213
INLINE_EXCLUDED_FIELD_TYPES,
1314
hasFieldEditWidget,
15+
isInlineExcludedFieldType,
1416
mapFieldTypeToFormType,
1517
} from './index';
1618

@@ -88,3 +90,44 @@ describe('inline editor ↔ form widget parity', () => {
8890
}
8991
});
9092
});
93+
94+
/**
95+
* #2942 — the guard above iterates FORM_FIELD_TYPES (the form widget-map
96+
* keys), so a SPEC field type that reaches the form only through the alias
97+
* table could sit in neither set and silently fall back to the grid's plain
98+
* text input: `json`, `composite`, `record`, `repeater`, `tree`, `video`,
99+
* `audio`, `autonumber` all did. This pins the contract at the spec boundary:
100+
* every `FieldType` member must resolve (alias-aware) to an inline editor or
101+
* a documented exclusion.
102+
*/
103+
describe('inline editor ↔ SPEC FieldType parity (#2942)', () => {
104+
const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options)
105+
? [...(FieldType as { options: readonly string[] }).options]
106+
: [];
107+
108+
it('reads a non-empty enum from the spec', () => {
109+
expect(specTypes, 'could not read FieldType.options from the spec').not.toEqual([]);
110+
});
111+
112+
it('every spec field type resolves to an inline decision (editor or exclusion)', () => {
113+
const undecided = specTypes.filter(
114+
(t) => !hasFieldEditWidget(t) && !isInlineExcludedFieldType(t),
115+
);
116+
// If this fails: a spec field type would fall back to a plain text input
117+
// inline. Give it a widget (EDIT_WIDGETS / the alias table) or a
118+
// documented exclusion (INLINE_EXCLUDED_FIELD_TYPES).
119+
expect(undecided).toEqual([]);
120+
});
121+
122+
it('the structured/computed spec spellings are closed corruption paths', () => {
123+
// Excluded (alias-aware): editing these through a text box corrupts the value.
124+
for (const t of ['composite', 'record', 'repeater', 'video', 'audio', 'autonumber']) {
125+
expect(isInlineExcludedFieldType(t), `${t} must be excluded from inline editing`).toBe(true);
126+
expect(hasFieldEditWidget(t), `${t} must not resolve to an editor`).toBe(false);
127+
}
128+
// Editable through their form widgets: json → code editor, tree → lookup picker.
129+
for (const t of ['json', 'tree']) {
130+
expect(hasFieldEditWidget(t), `${t} must resolve to its form widget`).toBe(true);
131+
}
132+
});
133+
});

0 commit comments

Comments
 (0)