From 0bab7cb31ad7938b71bd596a0cf637aa40ba2686 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:19:10 +0800 Subject: [PATCH] fix(spec-parity): the Tier-2 spec values render instead of validating into nothing (#2942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). - 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 ``. - 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. The export-menu row (PDF silently downloading nothing) was fixed concurrently by #2999, which this branch rebases onto and defers to. 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 --- .../app-shell/src/chrome/ThemeProvider.tsx | 9 +- .../toaster-position-spec-parity.test.tsx | 75 +++++++++ .../components/src/custom/filter-builder.tsx | 29 +++- .../src/renderers/feedback/toaster.tsx | 56 ++++++- packages/fields/package.json | 1 + packages/fields/src/FieldEditWidget.test.ts | 43 ++++++ packages/fields/src/FieldEditWidget.tsx | 40 ++++- packages/fields/src/field-type-alias.ts | 100 ++++++++++++ packages/fields/src/index.tsx | 96 +----------- .../src/widgets/FilterConditionField.tsx | 14 ++ .../FilterConditionField.operators.test.ts | 54 ++++++- packages/mobile/package.json | 1 + .../__tests__/gesture-spec-parity.test.tsx | 69 +++++++++ packages/mobile/src/useGesture.ts | 57 ++++++- packages/mobile/src/useSpecGesture.ts | 97 ++++++++++-- packages/plugin-charts/package.json | 1 + .../plugin-charts/src/AdvancedChartImpl.tsx | 50 +++++- .../__tests__/chart-type-spec-parity.test.tsx | 94 ++++++++++++ .../plugin-charts/src/normalizeChartSchema.ts | 26 +++- .../plugin-grid/src/inline-edit-options.ts | 23 ++- packages/plugin-list/src/UserFilters.tsx | 143 ++++++++++++++---- .../user-filter-arity-spec-parity.test.tsx | 79 +++++++++- packages/plugin-report/src/ReportViewer.tsx | 47 ++++-- .../report-aggregation-coverage.test.ts | 53 +++++++ .../timeline-scale-spec-parity.test.ts | 69 +++++++++ packages/plugin-timeline/src/renderer.tsx | 135 +++++++++++------ packages/providers/package.json | 1 + packages/providers/src/ThemeProvider.tsx | 6 +- .../__tests__/theme-mode-spec-parity.test.tsx | 88 +++++++++++ packages/providers/src/types.ts | 4 +- .../react/src/context/NotificationContext.tsx | 52 ++++++- ...nimation-notification-spec-parity.test.tsx | 134 ++++++++++++++++ packages/react/src/hooks/useAnimation.ts | 100 +++++++++--- .../react/src/hooks/useNavigationOverlay.ts | 38 ++++- packages/types/src/zod/objectql.zod.ts | 4 + pnpm-lock.yaml | 12 ++ 36 files changed, 1635 insertions(+), 265 deletions(-) create mode 100644 packages/components/src/__tests__/toaster-position-spec-parity.test.tsx create mode 100644 packages/fields/src/field-type-alias.ts create mode 100644 packages/mobile/src/__tests__/gesture-spec-parity.test.tsx create mode 100644 packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx create mode 100644 packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts create mode 100644 packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts create mode 100644 packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx create mode 100644 packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx diff --git a/packages/app-shell/src/chrome/ThemeProvider.tsx b/packages/app-shell/src/chrome/ThemeProvider.tsx index d89a82e11c..51d1936d17 100644 --- a/packages/app-shell/src/chrome/ThemeProvider.tsx +++ b/packages/app-shell/src/chrome/ThemeProvider.tsx @@ -1,6 +1,8 @@ import { createContext, useContext, useEffect, useState } from "react" -type Theme = "dark" | "light" | "system" +// `auto` is the spec's OS-following mode (ThemeModeSchema, ui/theme.zod.ts); +// `system` is this provider's pre-spec spelling, kept for stored values. +type Theme = "dark" | "light" | "auto" | "system" type ThemeProviderProps = { children: React.ReactNode @@ -35,7 +37,10 @@ export function ThemeProvider({ root.classList.remove("light", "dark") - if (theme === "system") { + // Branching on `system` alone sent the spec's `auto` into + // `classList.add('auto')` — a class no Tailwind variant matches, locking + // the light theme with the OS preference ignored (#2942). + if (theme === "auto" || theme === "system") { const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" diff --git a/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx b/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx new file mode 100644 index 0000000000..b354b2a0f3 --- /dev/null +++ b/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx @@ -0,0 +1,75 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Toaster position ↔ spec vocabulary parity + behavior (#2942). + * + * The `toaster` block used to mount `` bare with + * `inputs: []` — all six `NotificationPositionSchema` values (and `limit`) + * validated and were discarded. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import React from 'react'; +import '@testing-library/jest-dom'; +import { NotificationPositionSchema } from '@objectstack/spec/ui'; +import { renderComponent } from './test-utils'; +import { TOASTER_POSITIONS } from '../renderers/feedback/toaster'; + +// Sonner defers mounting its DOM container, so the behavior tests assert on +// the props our registration hands it rather than sonner internals. +vi.mock('../ui/sonner', () => ({ + Toaster: (props: { position?: string; visibleToasts?: number }) => ( +
+ ), + toast: Object.assign(() => undefined, { + success: () => undefined, + error: () => undefined, + info: () => undefined, + warning: () => undefined, + }), +})); + +beforeAll(async () => { + await import('../renderers'); +}, 30000); + +describe('toaster covers the spec notification-position vocabulary', () => { + const rawOptions = (NotificationPositionSchema as unknown as { options?: readonly string[] }).options; + const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : []; + + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read NotificationPositionSchema.options from the spec').not.toEqual([]); + }); + + it('maps every spec position onto a sonner position', () => { + const unmapped = specNames.filter((name) => !(name in TOASTER_POSITIONS)); + expect(unmapped, 'these validate and then the toaster discards them').toEqual([]); + }); + + it('maps only spec positions and the ToasterSchema hyphen dialect', () => { + const sonnerIds = new Set(Object.values(TOASTER_POSITIONS)); + const extra = Object.keys(TOASTER_POSITIONS).filter( + (name) => !specNames.includes(name) && !sonnerIds.has(name as never), + ); + expect(extra, 'unexpected renderer-local position dialect').toEqual([]); + }); +}); + +describe('toaster behavior', () => { + it('a spec underscore position (and limit) reaches sonner translated', () => { + const { getByTestId } = renderComponent({ type: 'toaster', position: 'top_center', limit: 3 } as any); + const toaster = getByTestId('sonner-mock'); + expect(toaster).toHaveAttribute('data-position', 'top-center'); + expect(toaster).toHaveAttribute('data-limit', '3'); + }); + + it('the hyphen dialect keeps working', () => { + const { getByTestId } = renderComponent({ type: 'toaster', position: 'bottom-left' } as any); + expect(getByTestId('sonner-mock')).toHaveAttribute('data-position', 'bottom-left'); + }); +}); diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 3cb3026b6f..99a221075e 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -71,6 +71,16 @@ const defaultOperators = [ { value: "between", label: "Between" }, { value: "in", label: "In" }, { value: "notIn", label: "Not in" }, + // String-specific spec operators ($startsWith / $endsWith) — they validated + // at the data layer but were unreachable from this dropdown (#2942). + { value: "startsWith", label: "Starts with" }, + { value: "endsWith", label: "Ends with" }, + // Null / existence spec operators ($null / $exists). Distinct from + // isEmpty/isNotEmpty, which also treat '' as empty. + { value: "isNull", label: "Is null" }, + { value: "isNotNull", label: "Is not null" }, + { value: "exists", label: "Is set" }, + { value: "notExists", label: "Is not set" }, ] as const /** @@ -121,16 +131,23 @@ const useSafeFilterTranslation = createSafeTranslation( 'filterBuilder.operators.between': 'Between', 'filterBuilder.operators.in': 'In', 'filterBuilder.operators.notIn': 'Not in', + 'filterBuilder.operators.startsWith': 'Starts with', + 'filterBuilder.operators.endsWith': 'Ends with', + 'filterBuilder.operators.isNull': 'Is null', + 'filterBuilder.operators.isNotNull': 'Is not null', + 'filterBuilder.operators.exists': 'Is set', + 'filterBuilder.operators.notExists': 'Is not set', }, 'filterBuilder.where', ) -const textOperators = ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"] -const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty"] +const NULLNESS_OPERATORS = ["isNull", "isNotNull", "exists", "notExists"] +const textOperators = ["equals", "notEquals", "contains", "notContains", "startsWith", "endsWith", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] +const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] const booleanOperators = ["equals", "notEquals"] -const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty"] -const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"] -const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"] +const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] +const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] +const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS] /** Field types that share the same operator/input behavior as number (numeric comparison operators, number input) */ const numberLikeTypes = ["number", "currency", "percent", "rating"] @@ -256,7 +273,7 @@ function FilterBuilder({ } const needsValueInput = (operator: string) => { - return !["isEmpty", "isNotEmpty"].includes(operator) + return !["isEmpty", "isNotEmpty", "isNull", "isNotNull", "exists", "notExists"].includes(operator) } const getInputType = (fieldValue: string) => { diff --git a/packages/components/src/renderers/feedback/toaster.tsx b/packages/components/src/renderers/feedback/toaster.tsx index 80d443bc30..fb98351fdd 100644 --- a/packages/components/src/renderers/feedback/toaster.tsx +++ b/packages/components/src/renderers/feedback/toaster.tsx @@ -10,14 +10,58 @@ import { ComponentRegistry } from '@object-ui/core'; import type { ToasterSchema } from '@object-ui/types'; import { Toaster as SonnerToaster } from '../../ui/sonner'; -ComponentRegistry.register('toaster', - () => { - return ; +/** + * Spec `NotificationPositionSchema` (`ui/notification.zod.ts`, underscore + * spellings) → sonner's hyphenated position ids. The hyphen spellings are + * `ToasterSchema`'s own dialect and map through as identities, so both + * registers work. Before #2942 the renderer mounted `` bare + * (`inputs: []`) and every authored position — all six spec values — plus + * `limit` validated and changed nothing. Exported for the spec-parity test. + */ +export const TOASTER_POSITIONS: Record< + string, + 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' +> = { + top_left: 'top-left', + top_center: 'top-center', + top_right: 'top-right', + bottom_left: 'bottom-left', + bottom_center: 'bottom-center', + bottom_right: 'bottom-right', + // ToasterSchema's hyphen dialect — identity entries. + 'top-left': 'top-left', + 'top-center': 'top-center', + 'top-right': 'top-right', + 'bottom-left': 'bottom-left', + 'bottom-center': 'bottom-center', + 'bottom-right': 'bottom-right', +}; + +ComponentRegistry.register( + 'toaster', + ({ schema }: { schema: ToasterSchema }) => { + const position = schema?.position ? TOASTER_POSITIONS[schema.position] : undefined; + const limit = typeof schema?.limit === 'number' && schema.limit > 0 ? schema.limit : undefined; + return ( + + ); }, { namespace: 'ui', label: 'Toaster', - inputs: [], - defaultProps: {} - } + inputs: [ + { + name: 'position', + type: 'enum', + enum: ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'], + defaultValue: 'bottom-right', + label: 'Position', + }, + { name: 'limit', type: 'number', label: 'Max visible toasts' }, + ], + defaultProps: {}, + }, ); diff --git a/packages/fields/package.json b/packages/fields/package.json index 5597021d95..6527518e29 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -49,6 +49,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@objectstack/spec": "^17.0.0-rc.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "^6.0.4", diff --git a/packages/fields/src/FieldEditWidget.test.ts b/packages/fields/src/FieldEditWidget.test.ts index 1e2169594f..4a263f8234 100644 --- a/packages/fields/src/FieldEditWidget.test.ts +++ b/packages/fields/src/FieldEditWidget.test.ts @@ -7,10 +7,12 @@ */ import { describe, it, expect } from 'vitest'; +import { FieldType } from '@objectstack/spec/data'; import { FORM_FIELD_TYPES, INLINE_EXCLUDED_FIELD_TYPES, hasFieldEditWidget, + isInlineExcludedFieldType, mapFieldTypeToFormType, } from './index'; @@ -88,3 +90,44 @@ describe('inline editor ↔ form widget parity', () => { } }); }); + +/** + * #2942 — the guard above iterates FORM_FIELD_TYPES (the form widget-map + * keys), so a SPEC field type that reaches the form only through the alias + * table could sit in neither set and silently fall back to the grid's plain + * text input: `json`, `composite`, `record`, `repeater`, `tree`, `video`, + * `audio`, `autonumber` all did. This pins the contract at the spec boundary: + * every `FieldType` member must resolve (alias-aware) to an inline editor or + * a documented exclusion. + */ +describe('inline editor ↔ SPEC FieldType parity (#2942)', () => { + const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options) + ? [...(FieldType as { options: readonly string[] }).options] + : []; + + it('reads a non-empty enum from the spec', () => { + expect(specTypes, 'could not read FieldType.options from the spec').not.toEqual([]); + }); + + it('every spec field type resolves to an inline decision (editor or exclusion)', () => { + const undecided = specTypes.filter( + (t) => !hasFieldEditWidget(t) && !isInlineExcludedFieldType(t), + ); + // If this fails: a spec field type would fall back to a plain text input + // inline. Give it a widget (EDIT_WIDGETS / the alias table) or a + // documented exclusion (INLINE_EXCLUDED_FIELD_TYPES). + expect(undecided).toEqual([]); + }); + + it('the structured/computed spec spellings are closed corruption paths', () => { + // Excluded (alias-aware): editing these through a text box corrupts the value. + for (const t of ['composite', 'record', 'repeater', 'video', 'audio', 'autonumber']) { + expect(isInlineExcludedFieldType(t), `${t} must be excluded from inline editing`).toBe(true); + expect(hasFieldEditWidget(t), `${t} must not resolve to an editor`).toBe(false); + } + // Editable through their form widgets: json → code editor, tree → lookup picker. + for (const t of ['json', 'tree']) { + expect(hasFieldEditWidget(t), `${t} must resolve to its form widget`).toBe(true); + } + }); +}); diff --git a/packages/fields/src/FieldEditWidget.tsx b/packages/fields/src/FieldEditWidget.tsx index 154ca14bc3..3b703cf33c 100644 --- a/packages/fields/src/FieldEditWidget.tsx +++ b/packages/fields/src/FieldEditWidget.tsx @@ -44,6 +44,9 @@ import { LocationField } from './widgets/LocationField'; import { GeolocationField } from './widgets/GeolocationField'; import { CodeField } from './widgets/CodeField'; import { QRCodeField } from './widgets/QRCodeField'; +// The FORM's spec-alias table (`json` → `field:code`, `tree` → `field:lookup`, +// …) — inline resolution reuses it so spec spellings get the form's decision. +import { mapFieldTypeToFormType } from './field-type-alias'; /** * Field types that edit in place with a dedicated widget. Keyed by the raw @@ -122,9 +125,39 @@ export const DISCRETE_EDIT_TYPES = new Set([ 'boolean', 'toggle', 'select', 'status', 'radio', 'rating', ]); +/** + * Resolve a field type to its inline-editing key: the raw type when the + * widget/exclusion tables name it directly, otherwise its form-alias target + * (`mapFieldTypeToFormType`, `field:` prefix stripped). + * + * The tables are keyed by the FORM's spellings, but spec field types reach + * this layer in the SPEC's spellings — `json`, `tree`, `composite`, `record`, + * `repeater`, `video`, `audio`, `autonumber` are aliases in the form map and + * were keys in NEITHER table here, so the grid silently fell back to a plain + * text input for all of them: typing over structured JSON or a hierarchy ref + * is a value-corruption path (#2942). Resolving through the same alias table + * the form uses gives each spec type the form's own decision: `json` → the + * code editor, `tree` → the lookup picker, and the container/computed/binary + * families → their documented exclusions. + */ +function resolveInlineEditType(type: string): string { + if (type in EDIT_WIDGETS || INLINE_EXCLUDED_FIELD_TYPES.has(type)) return type; + return mapFieldTypeToFormType(type).replace(/^field:/, ''); +} + /** True when a field type has a dedicated in-place edit widget. */ export function hasFieldEditWidget(type: string | undefined): boolean { - return !!type && type in EDIT_WIDGETS; + return !!type && resolveInlineEditType(type) in EDIT_WIDGETS; +} + +/** + * True when a field type is deliberately NOT inline-editable (alias-aware: + * `composite` resolves to the excluded `object`, `video` to `file`, …). + * Grid hosts must treat these as read-only cells — falling back to a plain + * text editor is exactly the corruption path the exclusion exists to close. + */ +export function isInlineExcludedFieldType(type: string | undefined): boolean { + return !!type && INLINE_EXCLUDED_FIELD_TYPES.has(resolveInlineEditType(type)); } /** @@ -148,8 +181,9 @@ export function FieldEditWidget({ onChange, readonly, }: FieldWidgetProps): React.ReactElement | null { - const Widget = field?.type ? EDIT_WIDGETS[field.type] : undefined; + const resolved = field?.type ? resolveInlineEditType(field.type) : undefined; + const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined; if (!Widget) return null; - const compactProps = field?.type && COMPACT_EDIT_TYPES.has(field.type) ? { compact: true } : {}; + const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {}; return ; } diff --git a/packages/fields/src/field-type-alias.ts b/packages/fields/src/field-type-alias.ts new file mode 100644 index 0000000000..aae9e88035 --- /dev/null +++ b/packages/fields/src/field-type-alias.ts @@ -0,0 +1,100 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Map field type to form component type + * + * @param fieldType - The ObjectQL field type identifier to convert + * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`). + * @returns The normalized form field type string used in the form schema + * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`). + */ +export function mapFieldTypeToFormType(fieldType: string): string { + const typeMap: Record = { + // Text-based fields + text: 'field:text', + textarea: 'field:textarea', + markdown: 'field:markdown', // Markdown editor (fallback to textarea) + html: 'field:html', // Rich text editor (fallback to textarea) + richtext: 'field:richtext', // WYSIWYG rich-text editor + secret: 'field:password', // encrypted-at-rest value — mask input like a password + + // Numeric fields + number: 'field:number', + currency: 'field:currency', + percent: 'field:percent', + slider: 'field:slider', + progress: 'field:slider', // bounded 0..100 progress — edit via slider + rating: 'field:rating', + + // Date/Time fields + date: 'field:date', + datetime: 'field:datetime', + time: 'field:time', + + // Boolean + boolean: 'field:boolean', + toggle: 'field:boolean', // toggle is a boolean rendered as a switch + + // Selection fields + select: 'field:select', + multiselect: 'field:multiselect', + radio: 'field:radio', + checkboxes: 'field:checkboxes', + tags: 'field:tags', + lookup: 'field:lookup', + master_detail: 'field:master_detail', + tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup + // `user` is a lookup specialized to sys_user; `owner` mirrors it (record + // ownership). Both render via the UserField person-picker (delegates to the + // lookup picker). Without these they would fall through to `field:text`. + user: 'field:user', + owner: 'field:owner', + + // Contact fields + email: 'field:email', + phone: 'field:phone', + url: 'field:url', + + // File / media fields + file: 'field:file', + image: 'field:image', + avatar: 'field:avatar', + video: 'field:file', // uploads as a file + audio: 'field:file', // uploads as a file + signature: 'field:signature', + + // Special / enhanced fields + password: 'field:password', + location: 'field:location', // Location/map field (fallback to input) + geolocation: 'field:geolocation', + address: 'field:address', + color: 'field:color', + code: 'field:code', + json: 'field:code', // JSON edited in the code editor + qrcode: 'field:qrcode', + vector: 'field:vector', + + // Embedded structured values (stored as JSON on the row) + object: 'field:object', + composite: 'field:object', // embedded object + record: 'field:object', // name-keyed map + repeater: 'field:grid', // embedded array of rows + + // Auto-generated/computed fields (typically read-only) + formula: 'field:formula', + summary: 'field:summary', + // `@objectstack/spec` spells this `autonumber`; the widget map key is + // `auto_number`. Map both spellings so a spec-typed field/param doesn't + // fall through to the plain text input. + autonumber: 'field:auto_number', + auto_number: 'field:auto_number', + }; + + return typeMap[fieldType] || 'field:text'; +} diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 707c6a3d8e..06a7ab7fef 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -1863,98 +1863,10 @@ setCellRendererResolver(getCellRenderer); -/** - * Map field type to form component type - * - * @param fieldType - The ObjectQL field type identifier to convert - * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`). - * @returns The normalized form field type string used in the form schema - * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`). - */ -export function mapFieldTypeToFormType(fieldType: string): string { - const typeMap: Record = { - // Text-based fields - text: 'field:text', - textarea: 'field:textarea', - markdown: 'field:markdown', // Markdown editor (fallback to textarea) - html: 'field:html', // Rich text editor (fallback to textarea) - richtext: 'field:richtext', // WYSIWYG rich-text editor - secret: 'field:password', // encrypted-at-rest value — mask input like a password - - // Numeric fields - number: 'field:number', - currency: 'field:currency', - percent: 'field:percent', - slider: 'field:slider', - progress: 'field:slider', // bounded 0..100 progress — edit via slider - rating: 'field:rating', - - // Date/Time fields - date: 'field:date', - datetime: 'field:datetime', - time: 'field:time', - - // Boolean - boolean: 'field:boolean', - toggle: 'field:boolean', // toggle is a boolean rendered as a switch - - // Selection fields - select: 'field:select', - multiselect: 'field:multiselect', - radio: 'field:radio', - checkboxes: 'field:checkboxes', - tags: 'field:tags', - lookup: 'field:lookup', - master_detail: 'field:master_detail', - tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup - // `user` is a lookup specialized to sys_user; `owner` mirrors it (record - // ownership). Both render via the UserField person-picker (delegates to the - // lookup picker). Without these they would fall through to `field:text`. - user: 'field:user', - owner: 'field:owner', - - // Contact fields - email: 'field:email', - phone: 'field:phone', - url: 'field:url', - - // File / media fields - file: 'field:file', - image: 'field:image', - avatar: 'field:avatar', - video: 'field:file', // uploads as a file - audio: 'field:file', // uploads as a file - signature: 'field:signature', - - // Special / enhanced fields - password: 'field:password', - location: 'field:location', // Location/map field (fallback to input) - geolocation: 'field:geolocation', - address: 'field:address', - color: 'field:color', - code: 'field:code', - json: 'field:code', // JSON edited in the code editor - qrcode: 'field:qrcode', - vector: 'field:vector', - - // Embedded structured values (stored as JSON on the row) - object: 'field:object', - composite: 'field:object', // embedded object - record: 'field:object', // name-keyed map - repeater: 'field:grid', // embedded array of rows - - // Auto-generated/computed fields (typically read-only) - formula: 'field:formula', - summary: 'field:summary', - // `@objectstack/spec` spells this `autonumber`; the widget map key is - // `auto_number`. Map both spellings so a spec-typed field/param doesn't - // fall through to the plain text input. - autonumber: 'field:auto_number', - auto_number: 'field:auto_number', - }; - - return typeMap[fieldType] || 'field:text'; -} +// `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so +// FieldEditWidget can resolve spec aliases without importing this barrel. +export { mapFieldTypeToFormType } from './field-type-alias'; +import { mapFieldTypeToFormType } from './field-type-alias'; /** * Formats file size in bytes to human-readable string diff --git a/packages/fields/src/widgets/FilterConditionField.tsx b/packages/fields/src/widgets/FilterConditionField.tsx index d149a58f73..bed3f2bfa0 100644 --- a/packages/fields/src/widgets/FilterConditionField.tsx +++ b/packages/fields/src/widgets/FilterConditionField.tsx @@ -122,8 +122,18 @@ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | // that convertFiltersToAST throws on, so every "does not contain" rule authored here // was rejected downstream. See kvToCondition for reading the old spelling back. case 'notContains': return { [field]: { $notContains: value } }; + // String-specific spec operators — previously unreachable from the + // builder UI even though FieldOperatorsSchema accepts them (#2942). + case 'startsWith': return { [field]: { $startsWith: value } }; + case 'endsWith': return { [field]: { $endsWith: value } }; case 'isEmpty': return { [field]: { $in: [null, ''] } }; case 'isNotEmpty': return { [field]: { $nin: [null, ''] } }; + // Null / existence spec operators. Distinct from isEmpty/isNotEmpty, + // which also treat '' as empty. + case 'isNull': return { [field]: { $null: true } }; + case 'isNotNull': return { [field]: { $null: false } }; + case 'exists': return { [field]: { $exists: true } }; + case 'notExists': return { [field]: { $exists: false } }; case 'greaterThan': case 'after': return { [field]: { $gt: cv } }; case 'lessThan': @@ -187,6 +197,10 @@ export function kvToCondition(field: string, v: any, idx: number): BuilderCondit case '$lt': return { id, field, operator: 'lessThan', value: val }; case '$gte': return { id, field, operator: 'greaterOrEqual', value: val }; case '$lte': return { id, field, operator: 'lessOrEqual', value: val }; + case '$startsWith': return { id, field, operator: 'startsWith', value: val }; + case '$endsWith': return { id, field, operator: 'endsWith', value: val }; + case '$null': return { id, field, operator: val === false ? 'isNotNull' : 'isNull', value: '' }; + case '$exists': return { id, field, operator: val === false ? 'notExists' : 'exists', value: '' }; case '$in': return arraysEqual(val, [null, '']) ? { id, field, operator: 'isEmpty', value: '' } diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index e7429f3fd4..df9ddaf617 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -21,14 +21,17 @@ * still carry. */ import { describe, it, expect } from 'vitest'; +import { FieldOperatorsSchema } from '@objectstack/spec/data'; import { condToMongo, kvToCondition } from '../FilterConditionField'; -/** The `$`-prefixed operator keys of the spec's `FieldOperatorsSchema`. */ -const SPEC_OPERATORS = new Set([ - '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', - '$between', '$contains', '$notContains', '$startsWith', '$endsWith', - '$null', '$exists', -]); +/** + * The `$`-prefixed operator keys of the spec's `FieldOperatorsSchema` — + * DERIVED from the schema (#2942), so a 16th operator landing in the spec + * fails the reachability test below instead of drifting unnoticed. + */ +const SPEC_OPERATORS = new Set( + Object.keys((FieldOperatorsSchema as unknown as { shape?: Record }).shape ?? {}), +); const noTypes = () => undefined; @@ -45,6 +48,7 @@ describe('condToMongo emits only spec operator spellings', () => { 'equals', 'notEquals', 'contains', 'notContains', 'isEmpty', 'isNotEmpty', 'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', 'in', 'notIn', 'before', 'after', + 'startsWith', 'endsWith', 'isNull', 'isNotNull', 'exists', 'notExists', ]; it.each(builderOperators)('%s emits a spec-defined operator', (operator) => { @@ -67,6 +71,38 @@ describe('condToMongo emits only spec operator spellings', () => { }); }); +describe('every spec field operator is reachable from the builder (#2942)', () => { + it('reads a non-empty operator vocabulary from the spec', () => { + expect([...SPEC_OPERATORS].length, 'could not read FieldOperatorsSchema.shape').toBeGreaterThan(0); + }); + + it('some builder operator emits every spec $-token', () => { + const builderOperators = [ + 'equals', 'notEquals', 'contains', 'notContains', 'isEmpty', 'isNotEmpty', + 'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', 'in', 'notIn', + 'before', 'after', 'between', + 'startsWith', 'endsWith', 'isNull', 'isNotNull', 'exists', 'notExists', + ]; + const emitted = new Set(); + for (const operator of builderOperators) { + const value = operator === 'in' || operator === 'notIn' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; + const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes); + for (const op of operatorsOf(frag)) emitted.add(op); + } + // `$eq` is the spec's IMPLICIT equality form — `equals` deliberately + // emits the bare `{ field: value }` shape, never an explicit `$eq`. + // `$between` stays covered by the `$gte`+`$lte` pair `between` emits + // (kvToCondition reads that pair back as `between`). + const unreachable = [...SPEC_OPERATORS].filter( + (op) => op !== '$eq' && op !== '$between' && !emitted.has(op), + ); + expect( + unreachable, + 'FieldOperatorsSchema accepts these but no builder operator can author them', + ).toEqual([]); + }); +}); + describe('kvToCondition round-trips what condToMongo writes', () => { const cases: Array<[string, unknown]> = [ ['notEquals', 'a'], @@ -76,6 +112,12 @@ describe('kvToCondition round-trips what condToMongo writes', () => { ['lessThan', 1], ['greaterOrEqual', 1], ['lessOrEqual', 1], + ['startsWith', 'a'], + ['endsWith', 'a'], + ['isNull', ''], + ['isNotNull', ''], + ['exists', ''], + ['notExists', ''], ]; it.each(cases)('%s survives the round trip', (operator, value) => { diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 46f67e5ba1..293749b81f 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -40,6 +40,7 @@ "@object-ui/types": "workspace:*" }, "devDependencies": { + "@objectstack/spec": "^17.0.0-rc.0", "@types/react": "19.2.17", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/mobile/src/__tests__/gesture-spec-parity.test.tsx b/packages/mobile/src/__tests__/gesture-spec-parity.test.tsx new file mode 100644 index 0000000000..787d74e821 --- /dev/null +++ b/packages/mobile/src/__tests__/gesture-spec-parity.test.tsx @@ -0,0 +1,69 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Gesture type ↔ spec vocabulary parity + dispatch (#2942). + * + * `useSpecGesture` never read `config.type` — it branched on which + * sub-object was present, so the spec's `pan` / `drag` / `rotate` / + * `double_tap` (types with no sub-object) all kept the `'tap'` initializer + * and fired on a tap. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { GestureTypeSchema } from '@objectstack/spec/ui'; +import { useSpecGesture, SPEC_GESTURE_TYPE_MAP } from '../useSpecGesture'; +import { useGesture } from '../useGesture'; + +vi.mock('../useGesture', () => ({ useGesture: vi.fn(() => ({ current: null })) })); + +const specNames: string[] = (() => { + const raw = (GestureTypeSchema as unknown as { options?: readonly string[] }).options; + return Array.isArray(raw) ? [...raw] : []; +})(); + +describe('useSpecGesture covers the spec gesture vocabulary', () => { + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read GestureTypeSchema.options from the spec').not.toEqual([]); + }); + + it('maps every spec gesture type onto a recognizer', () => { + const unmapped = specNames.filter((name) => !(name in SPEC_GESTURE_TYPE_MAP)); + expect(unmapped, 'these validate and then recognize as a plain tap').toEqual([]); + }); + + it('maps only spec gesture types', () => { + const extra = Object.keys(SPEC_GESTURE_TYPE_MAP).filter((name) => !specNames.includes(name)); + expect(extra, 'renderer-local gesture dialect — promote into @objectstack/spec instead').toEqual([]); + }); +}); + +describe('the declared type drives recognition', () => { + beforeEach(() => vi.mocked(useGesture).mockClear()); + + const recognizerFor = (config: Record) => { + renderHook(() => useSpecGesture({ config: config as never, onGesture: () => {} })); + return vi.mocked(useGesture).mock.calls.at(-1)?.[0]?.type; + }; + + it("'double_tap' / 'pan' / 'drag' / 'rotate' no longer collapse to tap", () => { + expect(recognizerFor({ type: 'double_tap' })).toBe('double-tap'); + expect(recognizerFor({ type: 'pan' })).toBe('pan'); + expect(recognizerFor({ type: 'drag' })).toBe('pan'); + expect(recognizerFor({ type: 'rotate' })).toBe('rotate'); + }); + + it('swipe resolves per configured direction', () => { + expect(recognizerFor({ type: 'swipe', swipe: { direction: ['up'] } })).toBe('swipe-up'); + }); + + it('legacy configs without a type still resolve from their sub-object', () => { + expect(recognizerFor({ longPress: { duration: 400 } })).toBe('long-press'); + expect(recognizerFor({ pinch: {} })).toBe('pinch'); + }); +}); diff --git a/packages/mobile/src/useGesture.ts b/packages/mobile/src/useGesture.ts index 7e7cce99ef..d9dda9fa4a 100644 --- a/packages/mobile/src/useGesture.ts +++ b/packages/mobile/src/useGesture.ts @@ -41,6 +41,12 @@ export function useGesture( const ref = useRef(null); const startRef = useRef<{ x: number; y: number; time: number } | null>(null); const longPressTimerRef = useRef>(undefined); + // Real double-tap needs the PREVIOUS tap's timestamp — the old + // implementation fired on every single qualifying tap (#2942). + const lastTapRef = useRef(0); + // Two-touch tracking for pinch/rotate: initial + latest distance/angle + // between the two touch points. + const twoTouchRef = useRef<{ d0: number; a0: number; d1: number; a1: number } | null>(null); const { type, onGesture, threshold = 50, longPressDuration = 500, enabled = true } = options; @@ -50,6 +56,16 @@ export function useGesture( const touch = e.touches[0]; startRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }; + // Pinch/rotate baseline — distance and angle between the two touches. + if ((type === 'pinch' || type === 'rotate') && e.touches.length >= 2) { + const [t1, t2] = [e.touches[0], e.touches[1]]; + const dx2 = t2.clientX - t1.clientX; + const dy2 = t2.clientY - t1.clientY; + const d = Math.hypot(dx2, dy2); + const a = (Math.atan2(dy2, dx2) * 180) / Math.PI; + twoTouchRef.current = { d0: d, a0: a, d1: d, a1: a }; + } + if (type === 'long-press') { longPressTimerRef.current = setTimeout(() => { if (startRef.current) { @@ -89,13 +105,40 @@ export function useGesture( velocity, }; + // Pinch / rotate resolve from the two-touch delta, not the single-touch + // path below. Fired once on release, like the other discrete gestures. + if (type === 'pinch' || type === 'rotate') { + const t2 = twoTouchRef.current; + twoTouchRef.current = null; + startRef.current = null; + if (!t2 || t2.d0 <= 0) return; + const scale = t2.d1 / t2.d0; + // Normalize the angle delta into (-180, 180]. + let rotation = t2.a1 - t2.a0; + if (rotation > 180) rotation -= 360; + if (rotation <= -180) rotation += 360; + if (type === 'pinch' && Math.abs(scale - 1) >= 0.1) { + onGesture({ ...context, scale }); + } else if (type === 'rotate' && Math.abs(rotation) >= 15) { + onGesture({ ...context, rotation }); + } + return; + } + // Detect gesture type if (type === 'tap' && distance < 10 && duration < 300) { onGesture(context); } else if (type === 'double-tap') { - // Double-tap handled separately (simplified) + // Two qualifying taps within 350ms — a single tap must NOT fire + // (it used to, collapsing double-tap into tap, #2942). if (distance < 10 && duration < 300) { - onGesture(context); + const now = Date.now(); + if (now - lastTapRef.current <= 350) { + lastTapRef.current = 0; + onGesture(context); + } else { + lastTapRef.current = now; + } } } else if (distance >= threshold) { const absX = Math.abs(dx); @@ -126,11 +169,19 @@ export function useGesture( [type, onGesture, threshold, enabled], ); - const handleTouchMove = useCallback(() => { + const handleTouchMove = useCallback((e: TouchEvent) => { // Cancel long-press if finger moves if (type === 'long-press') { clearTimeout(longPressTimerRef.current); } + // Track the latest two-touch distance/angle for pinch/rotate. + if ((type === 'pinch' || type === 'rotate') && twoTouchRef.current && e.touches.length >= 2) { + const [t1, t2] = [e.touches[0], e.touches[1]]; + const dx2 = t2.clientX - t1.clientX; + const dy2 = t2.clientY - t1.clientY; + twoTouchRef.current.d1 = Math.hypot(dx2, dy2); + twoTouchRef.current.a1 = (Math.atan2(dy2, dx2) * 180) / Math.PI; + } }, [type]); useEffect(() => { diff --git a/packages/mobile/src/useSpecGesture.ts b/packages/mobile/src/useSpecGesture.ts index 5016a5cf1e..f6b4de6356 100644 --- a/packages/mobile/src/useSpecGesture.ts +++ b/packages/mobile/src/useSpecGesture.ts @@ -18,6 +18,14 @@ export interface UseSpecGestureOptions { onPinch?: (scale: number) => void; /** Callback when a long-press gesture is detected */ onLongPress?: () => void; + /** Callback when a double-tap gesture is detected */ + onDoubleTap?: () => void; + /** Callback when a pan/drag gesture is detected (any-direction move) */ + onPan?: (direction: string) => void; + /** Callback when a rotate gesture is detected (degrees, CW positive) */ + onRotate?: (rotation: number) => void; + /** Fallback for gestures without a dedicated callback */ + onGesture?: (context: { type: string; direction?: string; scale?: number; rotation?: number }) => void; } const SWIPE_DIRECTION_MAP: Record = { @@ -27,6 +35,28 @@ const SWIPE_DIRECTION_MAP: Record = { down: 'swipe-down', }; +/** + * Spec `GestureTypeSchema` (`ui/touch.zod.ts`) → the recognizer type + * `useGesture` implements. The spec's `drag` and `pan` are one recognizer + * (any-direction move past the threshold); `swipe` resolves per configured + * direction, so it maps through {@link SWIPE_DIRECTION_MAP} instead. + * Exported for the spec-parity test. + * + * Before #2942 the hook never read `config.type` at all — it branched on + * which SUB-OBJECT was present (`swipe` / `pinch` / `longPress`), so + * `pan` / `drag` / `rotate` / `double_tap` (types with no sub-object) all + * fell through to the `'tap'` initializer and fired on a tap. + */ +export const SPEC_GESTURE_TYPE_MAP: Record = { + swipe: 'swipe-left', // per-direction; resolved via SWIPE_DIRECTION_MAP + pinch: 'pinch', + long_press: 'long-press', + double_tap: 'double-tap', + drag: 'pan', + pan: 'pan', + rotate: 'rotate', +}; + /** * Spec-aware gesture hook that maps an @objectstack/spec GestureConfig * to the existing useGesture hook. @@ -43,28 +73,63 @@ const SWIPE_DIRECTION_MAP: Record = { export function useSpecGesture( options: UseSpecGestureOptions, ) { - const { config, onSwipe, onPinch, onLongPress } = options; + const { config, onSwipe, onPinch, onLongPress, onDoubleTap, onPan, onRotate, onGesture: onAny } = options; const enabled = config.enabled ?? true; + // The DECLARED type drives recognition (#2942) — `config.type` is required + // by the spec's `GestureConfigSchema`. The sub-object presence checks below + // only back-fill legacy configs that predate the type field. + const declared: string | undefined = + typeof (config as { type?: unknown }).type === 'string' + ? ((config as { type?: string }).type as string) + : config.swipe + ? 'swipe' + : config.longPress + ? 'long_press' + : config.pinch + ? 'pinch' + : undefined; + let gestureType: GestureType = 'tap'; let threshold: number | undefined; let longPressDuration: number | undefined; - let onGesture: (ctx: { direction?: string; scale?: number }) => void = () => {}; + let onGesture: (ctx: { direction?: string; scale?: number; rotation?: number }) => void = () => {}; + const fallback = (ctx: { type: string; direction?: string; scale?: number; rotation?: number }) => onAny?.(ctx); - if (config.swipe && onSwipe) { - const dir = Array.isArray(config.swipe.direction) - ? config.swipe.direction[0] - : config.swipe.direction; - gestureType = (dir && SWIPE_DIRECTION_MAP[dir]) ?? 'swipe-left'; - threshold = config.swipe.threshold; - onGesture = (ctx) => onSwipe(ctx.direction ?? dir ?? 'left'); - } else if (config.longPress && onLongPress) { - gestureType = 'long-press'; - longPressDuration = config.longPress.duration; - onGesture = () => onLongPress(); - } else if (config.pinch && onPinch) { - gestureType = 'pinch'; - onGesture = (ctx) => onPinch(ctx.scale ?? 1); + switch (declared) { + case 'swipe': { + const dir = Array.isArray(config.swipe?.direction) + ? config.swipe?.direction[0] + : (config.swipe?.direction as string | undefined); + gestureType = (dir ? SWIPE_DIRECTION_MAP[dir] : undefined) ?? 'swipe-left'; + threshold = config.swipe?.threshold; + onGesture = (ctx) => (onSwipe ? onSwipe(ctx.direction ?? dir ?? 'left') : fallback({ type: 'swipe', ...ctx })); + break; + } + case 'long_press': + gestureType = 'long-press'; + longPressDuration = config.longPress?.duration; + onGesture = () => (onLongPress ? onLongPress() : fallback({ type: 'long_press' })); + break; + case 'pinch': + gestureType = 'pinch'; + onGesture = (ctx) => (onPinch ? onPinch(ctx.scale ?? 1) : fallback({ type: 'pinch', ...ctx })); + break; + case 'double_tap': + gestureType = 'double-tap'; + onGesture = () => (onDoubleTap ? onDoubleTap() : fallback({ type: 'double_tap' })); + break; + case 'pan': + case 'drag': + gestureType = 'pan'; + onGesture = (ctx) => (onPan ? onPan(ctx.direction ?? 'left') : fallback({ type: declared, ...ctx })); + break; + case 'rotate': + gestureType = 'rotate'; + onGesture = (ctx) => (onRotate ? onRotate(ctx.rotation ?? 0) : fallback({ type: 'rotate', ...ctx })); + break; + default: + break; } return useGesture({ diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json index 2a9afbd204..af97d85bfb 100644 --- a/packages/plugin-charts/package.json +++ b/packages/plugin-charts/package.json @@ -44,6 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@objectstack/spec": "^17.0.0-rc.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "^6.0.4", diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index f338784e2d..b0c5e7296e 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -47,7 +47,7 @@ import { ChartConfig } from './ChartContainerImpl'; import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents'; -import { formatterFor, domainFor, ticksFor, type NormalizedAxis, type NormalizedSeries } from './normalizeChartSchema'; +import { formatterFor, domainFor, ticksFor, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, type NormalizedAxis, type NormalizedSeries } from './normalizeChartSchema'; import { buildCategoryRank } from '@object-ui/core'; // Default color fallback for chart series @@ -571,6 +571,54 @@ export default function AdvancedChartImpl({ ...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end' as const, height: 60 }), }), [isMobile, hasLongLabels, xTickFormatter, xAxisSpec?.title]); + // #2942 — the non-series spec families used to fall through the component + // map's `|| BarChart` into a bar shell whose series marks all returned + // null: grid, axes, tooltip and legend rendered with NO data marks, + // indistinguishable from an empty dataset. Reachable because ChartRenderer + // resolves `schema.chartType ?? spec.chartType` without going through + // `normalizeChartSchema`'s RENDERABLE gate. Single-value families render + // the measure as a number (the spec's own framing for them); tabular ones + // say which component owns the rendering; unknown values are named instead + // of guessed at. + if (chartType && SINGLE_VALUE_CHART_TYPES.has(chartType)) { + const dataKey = series[0]?.dataKey || 'value'; + const raw = data[0]?.[dataKey]; + const num = typeof raw === 'number' ? raw : Number(raw); + const label = series[0]?.label ?? (config?.[dataKey] as { label?: unknown } | undefined)?.label ?? dataKey; + return ( +
+
+ + {Number.isFinite(num) ? new Intl.NumberFormat().format(num) : String(raw ?? '—')} + + {String(label)} +
+
+ ); + } + if (chartType && TABULAR_CHART_TYPES.has(chartType)) { + return ( +
+ Chart type “{chartType}” is tabular — render it with the data-table / pivot components; a chart block draws series charts. +
+ ); + } + if (chartType && !RENDERABLE.has(chartType)) { + return ( +
+ Chart type “{chartType}” is not a spec chart type — nothing was drawn. +
+ ); + } + // Pie and Donut charts if (chartType === 'pie' || chartType === 'donut') { const innerRadius = chartType === 'donut' ? '52%' : 0; diff --git a/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx b/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx new file mode 100644 index 0000000000..1ac8462804 --- /dev/null +++ b/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx @@ -0,0 +1,94 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Chart type ↔ spec vocabulary parity + behavior (#2942). + * + * 7 of the 19 `ChartTypeSchema` values (the single-value and tabular + * families) used to fall through the cartesian component map's `|| BarChart` + * into a bar shell whose series marks all returned null — grid, axes, + * tooltip and legend rendered with NO data marks, indistinguishable from an + * empty dataset. Reachable because `ChartRenderer` reads + * `schema.chartType ?? spec.chartType`, bypassing `normalizeChartSchema`'s + * `RENDERABLE` gate. + * + * Contract under test: + * - parity: RENDERABLE ∪ SINGLE_VALUE ∪ TABULAR covers every spec value, and + * the only renderer-local dialect is the documented `combo`; + * - behavior: a single-value type renders the number, a tabular type renders + * the ownership notice, an out-of-spec type is named — never a silent + * empty plot. + */ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ChartTypeSchema } from '@objectstack/spec/ui'; +import AdvancedChartImpl from '../AdvancedChartImpl'; +import { RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES } from '../normalizeChartSchema'; + +const specNames: string[] = (() => { + const raw = (ChartTypeSchema as unknown as { options?: readonly string[] }).options; + return Array.isArray(raw) ? [...raw] : []; +})(); + +describe('plugin-charts covers the spec chart-type vocabulary', () => { + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read ChartTypeSchema.options from the spec').not.toEqual([]); + }); + + it('every spec chart type lands on a real branch (series, single-value, or tabular)', () => { + const uncovered = specNames.filter( + (name) => !RENDERABLE.has(name) && !SINGLE_VALUE_CHART_TYPES.has(name) && !TABULAR_CHART_TYPES.has(name), + ); + expect( + uncovered, + 'these validate against ChartTypeSchema and would draw a silent empty plot', + ).toEqual([]); + }); + + it("the only renderer-local dialect is the documented 'combo'", () => { + const implemented = [...RENDERABLE, ...SINGLE_VALUE_CHART_TYPES, ...TABULAR_CHART_TYPES]; + const dialect = implemented.filter((name) => !specNames.includes(name)); + // `combo` is tracked in #2945 ("promote or delete"); anything else + // appearing here is NEW dialect and needs the same decision first. + expect(dialect).toEqual(['combo']); + }); +}); + +describe('non-series chart families render honestly', () => { + const data = [{ label: 'Total', value: 1234 }]; + const series = [{ dataKey: 'value', label: 'Total revenue' }]; + + it('a single-value type (kpi) renders the number, not an empty bar shell', () => { + render(); + const card = screen.getByTestId('advanced-chart-single-value'); + expect(card.textContent).toContain('1,234'); + expect(card.textContent).toContain('Total revenue'); + }); + + it('every single-value family renders the same card', () => { + for (const t of SINGLE_VALUE_CHART_TYPES) { + const { unmount, getByTestId } = render( + , + ); + expect(getByTestId('advanced-chart-single-value'), `type '${t}'`).toBeInTheDocument(); + unmount(); + } + }); + + it("a tabular type ('table') names the owning component instead of an empty plot", () => { + render(); + expect(screen.getByTestId('advanced-chart-tabular-notice').textContent).toContain('table'); + }); + + it('an out-of-spec type is named rather than guessed at', () => { + render(); + expect(screen.getByTestId('advanced-chart-unknown-type').textContent).toContain('sunburst'); + }); +}); diff --git a/packages/plugin-charts/src/normalizeChartSchema.ts b/packages/plugin-charts/src/normalizeChartSchema.ts index 5bc47edee2..83a5086290 100644 --- a/packages/plugin-charts/src/normalizeChartSchema.ts +++ b/packages/plugin-charts/src/normalizeChartSchema.ts @@ -54,7 +54,7 @@ export type ChartFamily = | 'treemap' | 'sankey' | 'combo'; -const RENDERABLE = new Set([ +export const RENDERABLE = new Set([ 'bar', 'column', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'funnel', @@ -72,10 +72,30 @@ const RENDERABLE = new Set([ * whether a bare `type` is a chart family or the SDUI envelope's component * discriminator, and getting THAT wrong would break the block outright. */ +/** + * The spec's single-value chart families (`ChartTypeSchema`, whose own + * comment calls gauge/solid-gauge/bullet "honest single-value variants + * pending a real dial/target renderer"). The chart draws these as a number — + * they used to fall through the cartesian component map's `|| BarChart` into + * a bar SHELL whose series marks all returned null: grid, axes, tooltip and + * legend rendered with no data marks, indistinguishable from an empty + * dataset (#2942). Exported for the dispatch and the spec-parity test. + */ +export const SINGLE_VALUE_CHART_TYPES: ReadonlySet = new Set([ + 'gauge', 'solid-gauge', 'metric', 'kpi', 'bullet', +]); + +/** + * The spec's tabular chart families — a table is not a series chart; these + * render through the data-table / pivot components, and the chart block says + * so instead of drawing the same silent empty plot (#2942). + */ +export const TABULAR_CHART_TYPES: ReadonlySet = new Set(['table', 'pivot']); + const CHART_TYPES = new Set([ ...RENDERABLE, - 'gauge', 'solid-gauge', 'metric', 'kpi', 'bullet', - 'table', 'pivot', + ...SINGLE_VALUE_CHART_TYPES, + ...TABULAR_CHART_TYPES, ]); export type AnyRec = Record; diff --git a/packages/plugin-grid/src/inline-edit-options.ts b/packages/plugin-grid/src/inline-edit-options.ts index 239a8f3e56..9ea1fbff18 100644 --- a/packages/plugin-grid/src/inline-edit-options.ts +++ b/packages/plugin-grid/src/inline-edit-options.ts @@ -12,6 +12,8 @@ * is bound to reject. */ +import { isInlineExcludedFieldType } from '@object-ui/fields'; + /** * For a field governed by a `state_machine` validation, the set of values * reachable from `currentValue` — the current value itself plus its declared @@ -54,9 +56,12 @@ export function stateMachineNextValues( * - binary / attachment — there is no text/inline control for a file's bytes. * * Without this gate the grid falls back to a plain text box for these (you - * could type into a Formula or File cell), which is wrong. Relational and - * structured types are intentionally NOT here — they degrade to a text editor - * today and want a proper picker later, not a hard read-only lock. + * could type into a Formula or File cell), which is wrong. Structured types + * (`json`, `composite`, `record`, `repeater`, `tree`, …) are not listed here + * because the FIELDS package owns their decision — `isFieldInlineEditable` + * also consults its alias-aware exclusion contract below (#2942); before + * that, a `composite` cell degraded to a plain text editor, a + * value-corruption path. */ const NON_EDITABLE_FIELD_TYPES = new Set([ // computed / system-generated @@ -67,14 +72,18 @@ const NON_EDITABLE_FIELD_TYPES = new Set([ /** * Whether a field may be edited in place in the grid. False for explicitly - * `readonly` fields and for inherently computed / binary types (see - * {@link NON_EDITABLE_FIELD_TYPES}). A null/unknown field is treated as - * editable so the grid's own `editable` flag still governs. + * `readonly` fields, for inherently computed / binary types (see + * {@link NON_EDITABLE_FIELD_TYPES}), and for every type the fields package + * excludes from inline editing (`isInlineExcludedFieldType` — alias-aware, + * so `composite` → `object`, `repeater` → `grid` are read-only cells, never + * a plain text box). A null/unknown field is treated as editable so the + * grid's own `editable` flag still governs. */ export function isFieldInlineEditable( fieldDef: { type?: unknown; readonly?: unknown } | null | undefined, ): boolean { if (!fieldDef) return true; if (fieldDef.readonly === true) return false; - return !(typeof fieldDef.type === 'string' && NON_EDITABLE_FIELD_TYPES.has(fieldDef.type)); + if (typeof fieldDef.type !== 'string') return true; + return !NON_EDITABLE_FIELD_TYPES.has(fieldDef.type) && !isInlineExcludedFieldType(fieldDef.type); } diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index 2fb8739c56..8094926806 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -48,25 +48,27 @@ interface ResolvedField { const LOOKUP_LIKE_TYPES = new Set(['lookup', 'master_detail', 'user', 'owner']); /** - * Selection arity for every filter control type the spec's - * `UserFilterFieldSchema.type` (`ui/view.zod.ts`) publishes. The enum names - * BOTH `select` and `multi-select`, so `select` necessarily means - * single-choice — rendering it as accumulating checkboxes made a single-choice - * filter silently accept many values (#2941). + * Control kind for every filter control type the spec's + * `UserFilterFieldSchema.type` (`ui/view.zod.ts`) publishes: + * + * - the enum names BOTH `select` and `multi-select`, so `select` necessarily + * means single-choice — rendering it as accumulating checkboxes made a + * single-choice filter silently accept many values (#2941); + * - `date-range` and `text` used to be dead controls — the chip rendered and + * the popover said the literal "No options" (#2942). They now render a + * from/to date pair and a search input. * * Applies to the AUTHORED `type` only: when the author omits it, the control * is inferred from the field definition and keeps the historical multi-check * UX. Exported for the spec-parity test, which fails the moment the spec's * vocabulary and this table drift in either direction. */ -export const FILTER_CONTROL_ARITY: Record = { - select: 'single', - 'multi-select': 'multiple', - boolean: 'multiple', - // Dead controls today (their popovers render "No options" — #2942); their - // arity is still declared so the vocabulary stays fully mapped. - 'date-range': 'single', - text: 'single', +export const FILTER_CONTROL_KINDS: Record = { + select: 'single-choice', + 'multi-select': 'multi-choice', + boolean: 'multi-choice', + 'date-range': 'range', + text: 'text', }; export interface UserFiltersProps { @@ -155,7 +157,10 @@ export function UserFilters({ initialSelections, onSelectionsChange, }: UserFiltersProps) { - switch (config.element) { + // The AUTHORING type (ADR-0053) only admits dropdown/tabs; stored metadata + // still carries the spec-deprecated `toggle`, which must keep rendering + // (ADR-0047 §3.4a) — hence the wider comparand. + switch (config.element as 'dropdown' | 'tabs' | 'toggle') { case 'dropdown': return ( ); + // DEPRECATED in the spec (ADR-0047 §3.4a: "kept in the enum so existing + // configs keep rendering; do not author new `toggle` filters") — but the + // compatibility promise is the renderer's to keep, and `default: return + // null` was deleting the ENTIRE filter bar for stored toggle configs + // (#2942). Authoring tooling no longer offers it; this branch only keeps + // old metadata working. + case 'toggle': + return ( + + ); default: return null; } @@ -311,12 +330,12 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, const { fieldLabel, translateOptions } = useSafeFieldLabel(); const moreLabel = useMoreLabel(); const objectName: string | undefined = objectDef?.name; - // Fields whose AUTHORED control type is single-choice (`type: 'select'`). - // Keyed off the raw config, not the resolved field: `resolveFields` back-fills - // `type` from the object definition, and an inferred type must keep the - // historical multi-check UX (#2941). - const singleChoiceFields = React.useMemo( - () => new Set(fields.filter(f => FILTER_CONTROL_ARITY[f.type ?? ''] === 'single').map(f => f.field)), + // Control kind per field, from the AUTHORED type only. Keyed off the raw + // config, not the resolved field: `resolveFields` back-fills `type` from + // the object definition, and an inferred type must keep the historical + // multi-check UX (#2941). + const controlKinds = React.useMemo( + () => new Map(fields.map(f => [f.field, FILTER_CONTROL_KINDS[f.type ?? '']])), [fields], ); const [selectedValues, setSelectedValues] = React.useState< @@ -333,8 +352,9 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, init[f.field] = restored; } // A single-choice control can never hold more than one value, whatever - // an author default or a hand-edited URL claims. - if (singleChoiceFields.has(f.field) && (init[f.field]?.length ?? 0) > 1) { + // an author default or a hand-edited URL claims. (A `range` holds its + // [from, to] pair; `text` its one query — both self-limit.) + if (controlKinds.get(f.field) === 'single-choice' && (init[f.field]?.length ?? 0) > 1) { init[f.field] = init[f.field].slice(0, 1); } }); @@ -369,12 +389,30 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, const emitFilters = React.useCallback( (next: Record) => { - const conditions = Object.entries(next) - .filter(([, v]) => v.length > 0) - .map(([field, values]) => [field, 'in', values]); + // Condition shape per control kind (#2942): a `range` lowers to + // >=/<= bounds, `text` to a contains query; option controls keep the + // historical `in` set. + const conditions = Object.entries(next).flatMap(([field, values]) => { + if (values.length === 0) return []; + switch (controlKinds.get(field)) { + case 'range': { + const [from, to] = values as [unknown?, unknown?]; + const bounds: Array<[string, string, unknown]> = []; + if (from !== undefined && from !== '') bounds.push([field, '>=', from]); + if (to !== undefined && to !== '') bounds.push([field, '<=', to]); + return bounds; + } + case 'text': { + const query = String(values[0] ?? '').trim(); + return query ? [[field, 'contains', query] as [string, string, unknown]] : []; + } + default: + return [[field, 'in', values] as [string, string, unknown]]; + } + }); onFilterChange(conditions); }, - [onFilterChange], + [onFilterChange, controlKinds], ); const handleChange = (field: string, values: (string | number | boolean)[]) => { @@ -423,8 +461,11 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, const renderBadge = (f: ResolvedField) => { const selected = selectedValues[f.field] || []; - const hasSelection = selected.length > 0; - const singleChoice = singleChoiceFields.has(f.field); + const kind = controlKinds.get(f.field); + // A range stores ['', to] / [from, ''] placeholders — count real values. + const activeCount = selected.filter(v => v !== '' && v !== undefined && v !== null).length; + const hasSelection = activeCount > 0; + const singleChoice = kind === 'single-choice'; const isLookupLike = LOOKUP_LIKE_TYPES.has(f.type || '') && f.options.length === 0 && @@ -446,7 +487,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, {f.label || f.field} {hasSelection && ( - {selected.length} + {activeCount} )} {hasSelection ? ( @@ -487,6 +528,50 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, }} />
+ ) : kind === 'range' ? ( + // Authored `type: 'date-range'` — a from/to day-granularity pair. + // Used to render the literal "No options" dead control (#2942). +
+ {([0, 1] as const).map((slot) => ( + + ))} +
+ ) : kind === 'text' ? ( + // Authored `type: 'text'` — a contains query, committed on Enter / + // blur so typing doesn't refire the list query per keystroke. + { + const q = e.target.value.trim(); + handleChange(f.field, q ? [q] : []); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const q = (e.target as HTMLInputElement).value.trim(); + handleChange(f.field, q ? [q] : []); + } + }} + className="h-8 w-full rounded border border-input bg-background px-2 text-xs text-foreground" + /> ) : (
{f.options.length === 0 ? ( diff --git a/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx b/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx index 123828e39e..bbc00d1d03 100644 --- a/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx +++ b/packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx @@ -17,7 +17,7 @@ * the authored contract. * * Contract under test: - * - parity: `FILTER_CONTROL_ARITY` maps exactly the spec's vocabulary; + * - parity: `FILTER_CONTROL_KINDS` maps exactly the spec's vocabulary; * - authored `type: 'select'` renders radios and replaces the pick; * - authored `type: 'multi-select'` (and an omitted, inferred type) keeps * accumulating checkboxes; @@ -27,7 +27,7 @@ import * as React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { UserFilterFieldSchema } from '@objectstack/spec/ui'; -import { UserFilters, FILTER_CONTROL_ARITY } from '../UserFilters'; +import { UserFilters, FILTER_CONTROL_KINDS } from '../UserFilters'; const objectDef = { name: 'tasks', @@ -52,7 +52,7 @@ function specControlTypes(): string[] { return Array.isArray(options) ? [...options] : []; } -describe('FILTER_CONTROL_ARITY covers the spec user-filter control vocabulary', () => { +describe('FILTER_CONTROL_KINDS covers the spec user-filter control vocabulary', () => { const specNames = specControlTypes(); it('reads a non-empty enum from the spec', () => { @@ -60,15 +60,15 @@ describe('FILTER_CONTROL_ARITY covers the spec user-filter control vocabulary', }); it('declares an arity for every control type the spec accepts', () => { - const undeclared = specNames.filter((name) => !(name in FILTER_CONTROL_ARITY)); + const undeclared = specNames.filter((name) => !(name in FILTER_CONTROL_KINDS)); expect( undeclared, - 'these pass schema validation with no declared selection arity — add them to FILTER_CONTROL_ARITY', + 'these pass schema validation with no declared control kind — add them to FILTER_CONTROL_KINDS', ).toEqual([]); }); it('does not declare control types the spec rejects', () => { - const extra = Object.keys(FILTER_CONTROL_ARITY).filter((name) => !specNames.includes(name)); + const extra = Object.keys(FILTER_CONTROL_KINDS).filter((name) => !specNames.includes(name)); expect( extra, 'these are renderer-local dialect — promote them into @objectstack/spec instead', @@ -149,3 +149,70 @@ describe("filter type 'select' is single-choice", () => { expect(onFilterChange).toHaveBeenLastCalledWith([['status', 'in', ['todo']]]); }); }); + +describe("Tier 2 (#2942): 'toggle' element and range/text controls render", () => { + it("element: 'toggle' renders the filter bar instead of deleting it", () => { + const onFilterChange = vi.fn(); + render( + , + ); + + // The whole bar used to vanish (`default: return null`). + expect(screen.getByTestId('user-filters-toggle')).toBeInTheDocument(); + const btn = screen.getByTestId('filter-toggle-status'); + // Default-active toggle emitted its filter on mount; clicking it off clears. + expect(onFilterChange).toHaveBeenLastCalledWith([['status', 'in', ['todo']]]); + fireEvent.click(btn); + expect(onFilterChange).toHaveBeenLastCalledWith([]); + }); + + it("'date-range' renders a from/to pair and emits >=/<= bounds", () => { + const onFilterChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('filter-badge-due_date')); + expect(screen.getByTestId('filter-range-due_date')).toBeInTheDocument(); + expect(screen.queryByText('No options')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('filter-range-due_date-from'), { target: { value: '2026-01-01' } }); + expect(onFilterChange).toHaveBeenLastCalledWith([['due_date', '>=', '2026-01-01']]); + + fireEvent.change(screen.getByTestId('filter-range-due_date-to'), { target: { value: '2026-02-01' } }); + expect(onFilterChange).toHaveBeenLastCalledWith([ + ['due_date', '>=', '2026-01-01'], + ['due_date', '<=', '2026-02-01'], + ]); + }); + + it("'text' renders a search input and emits a contains query on Enter", () => { + const onFilterChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('filter-badge-name')); + const input = screen.getByTestId('filter-text-name'); + expect(screen.queryByText('No options')).not.toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'acme' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onFilterChange).toHaveBeenLastCalledWith([['name', 'contains', 'acme']]); + }); +}); diff --git a/packages/plugin-report/src/ReportViewer.tsx b/packages/plugin-report/src/ReportViewer.tsx index 8b4918a020..d731cfacde 100644 --- a/packages/plugin-report/src/ReportViewer.tsx +++ b/packages/plugin-report/src/ReportViewer.tsx @@ -50,6 +50,37 @@ function groupData(data: any[], groupBy: ReportGroupBy[]): GroupedData[] | null return result; } +/** + * Compute one summary value for a report column. Covers every + * `ReportAggregationType` member (`@object-ui/types/reports.ts`) — `distinct` + * used to fall into `default: return ''` and render a blank cell while the + * type accepted it (#2942). Exported for the coverage guard, whose sample + * table is `Record` so a new member fails + * type-check until it computes here. + */ +export function computeReportAggregation( + data: Record[] | undefined, + fieldName: string, + aggregation?: string, +): string | number { + if (!data) return 0; + const values = data.map((item: Record) => Number(item[fieldName]) || 0); + switch (aggregation) { + case 'count': return data.length; + case 'sum': return values.reduce((sum: number, v: number) => sum + v, 0); + case 'avg': return values.length > 0 + ? (values.reduce((sum: number, v: number) => sum + v, 0) / values.length).toFixed(2) + : 0; + case 'min': return values.length > 0 ? Math.min(...values) : 0; + case 'max': return values.length > 0 ? Math.max(...values) : 0; + // Distinct values of the RAW field (numeric coercion would fold distinct + // strings together). + case 'distinct': + return new Set(data.map((item: Record) => item[fieldName])).size; + default: return ''; + } +} + export interface ReportViewerProps { schema: ReportViewerSchema; /** Callback to refresh data */ @@ -88,20 +119,8 @@ export const ReportViewer: React.FC = ({ schema, onRefresh }) onRefresh?.(); }; - const computeAggregation = (fieldName: string, aggregation?: string): string | number => { - if (!data) return 0; - const values = data.map((item: Record) => Number(item[fieldName]) || 0); - switch (aggregation) { - case 'count': return data.length; - case 'sum': return values.reduce((sum: number, v: number) => sum + v, 0); - case 'avg': return values.length > 0 - ? (values.reduce((sum: number, v: number) => sum + v, 0) / values.length).toFixed(2) - : 0; - case 'min': return values.length > 0 ? Math.min(...values) : 0; - case 'max': return values.length > 0 ? Math.max(...values) : 0; - default: return ''; - } - }; + const computeAggregation = (fieldName: string, aggregation?: string): string | number => + computeReportAggregation(data, fieldName, aggregation); // Evaluate conditional formatting rules for a cell const getCellStyle = (fieldName: string, value: any): React.CSSProperties | undefined => { diff --git a/packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts b/packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts new file mode 100644 index 0000000000..8d939d677f --- /dev/null +++ b/packages/plugin-report/src/__tests__/report-aggregation-coverage.test.ts @@ -0,0 +1,53 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Report column aggregation coverage (#2942). + * + * `ReportAggregationType` (`@object-ui/types/reports.ts`) is the contract for + * a report column's `aggregation`; `distinct` used to fall into + * `default: return ''` and render a blank summary cell while the type + * accepted it. The sample table below is `Record`, + * so a NEW union member fails type-check here until someone teaches + * `computeReportAggregation` to compute it — the #2897 guard shape, keyed to + * the local vocabulary because this legacy viewer's contract is the types + * package, not a spec enum. + */ +import { describe, it, expect } from 'vitest'; +import type { ReportAggregationType } from '@object-ui/types'; +import { computeReportAggregation } from '../ReportViewer'; + +const rows = [ + { amount: 10, stage: 'won' }, + { amount: 10, stage: 'lost' }, + { amount: 30, stage: 'won' }, +]; + +describe('computeReportAggregation covers ReportAggregationType', () => { + const expected: Record = { + count: 3, + sum: 50, + avg: '16.67', + min: 10, + max: 30, + distinct: 2, // distinct raw values of `stage` — was a blank cell before #2942 + }; + + for (const [aggregation, value] of Object.entries(expected)) { + it(`computes '${aggregation}' (never a blank cell)`, () => { + const field = aggregation === 'distinct' ? 'stage' : 'amount'; + const result = computeReportAggregation(rows, field, aggregation); + expect(result).toBe(value); + expect(result, `'${aggregation}' must never render blank`).not.toBe(''); + }); + } + + it('an out-of-vocabulary aggregation still yields the empty sentinel', () => { + expect(computeReportAggregation(rows, 'amount', 'median')).toBe(''); + }); +}); diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts new file mode 100644 index 0000000000..697f0418ab --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts @@ -0,0 +1,69 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Timeline scale ↔ spec vocabulary parity + behavior (#2942). + * + * Two drifts lived here: the renderer read its own `timeScale` key, so the + * spec's `scale` (`TimelineConfigSchema`) was ignored for ALL six values; + * and the header generator only knew month/week/day, so `hour` / `quarter` / + * `year` produced zero header columns — a blank gantt axis. + */ +import { describe, it, expect } from 'vitest'; +import { TimelineConfigSchema } from '@objectstack/spec/ui'; +import { TIMELINE_SCALES, resolveTimelineScale, generateTimeScaleHeaders } from '../renderer'; + +function specScales(): string[] { + const scaleSchema = (TimelineConfigSchema as unknown as { shape?: Record }) + .shape?.scale as { def?: { innerType?: { options?: readonly string[] } } } | undefined; + const options = scaleSchema?.def?.innerType?.options; + return Array.isArray(options) ? [...options] : []; +} + +describe('timeline covers the spec scale vocabulary', () => { + const specNames = specScales(); + + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read TimelineConfigSchema.shape.scale options from the spec').not.toEqual([]); + }); + + it('declares exactly the spec scales', () => { + expect([...TIMELINE_SCALES].sort()).toEqual([...specNames].sort()); + }); + + it('every spec scale generates a non-empty gantt header row', () => { + for (const scale of specNames) { + const headers = generateTimeScaleHeaders(scale, '2026-01-01', '2026-01-02'); + expect(headers.length, `scale '${scale}' blanked the axis`).toBeGreaterThan(0); + } + }); + + it('quarter and year headers read as buckets, not blanks', () => { + expect(generateTimeScaleHeaders('quarter', '2026-01-15', '2026-08-01')).toEqual(['Q1 2026', 'Q2 2026', 'Q3 2026']); + expect(generateTimeScaleHeaders('year', '2025-06-01', '2027-01-01')).toEqual(['2025', '2026', '2027']); + }); +}); + +describe('resolveTimelineScale honors the spec key first', () => { + it('reads the spec `scale` key', () => { + expect(resolveTimelineScale({ scale: 'quarter' })).toBe('quarter'); + }); + + it('keeps the legacy `timeScale` dialect for stored JSON', () => { + expect(resolveTimelineScale({ timeScale: 'day' })).toBe('day'); + }); + + it('the spec key wins when both are present', () => { + expect(resolveTimelineScale({ scale: 'year', timeScale: 'day' })).toBe('year'); + }); + + it("absent/unknown values keep the renderer's historical month default", () => { + expect(resolveTimelineScale({})).toBe('month'); + expect(resolveTimelineScale({ scale: 'fortnight' })).toBe('month'); + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 91b0f66c2c..4b94837743 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -31,7 +31,87 @@ import { import { renderChildren, cn } from '@object-ui/components'; // Constants -const MILLISECONDS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; +/** + * The spec's timeline scale vocabulary (`ui/view.zod.ts` + * `TimelineConfigSchema.scale`). Exported for the spec-parity test. + */ +export const TIMELINE_SCALES: ReadonlySet = new Set([ + 'hour', 'day', 'week', 'month', 'quarter', 'year', +]); + +/** + * Resolve the axis scale for the gantt variant. The spec key is `scale`; + * `timeScale` is this renderer's pre-spec dialect, kept for stored JSON — + * before #2942 ONLY `timeScale` was read, so every spec-authored `scale` + * (all six values) was silently ignored. An absent/unknown value keeps the + * renderer's historical `month` default. The `vertical` / `horizontal` + * variants are sequential event feeds with no time axis, so `scale` has + * nothing to bucket there by construction. + */ +export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unknown }): string { + const raw = schema.scale ?? schema.timeScale; + return typeof raw === 'string' && TIMELINE_SCALES.has(raw) ? raw : 'month'; +} + +/** + * Gantt header labels for one scale across [minDate, maxDate]. Every spec + * scale produces a non-empty header row — `hour` / `quarter` / `year` used to + * fall through the month/week/day chain and return `[]`, blanking the axis + * (#2942). Exported for the spec-parity test. + */ +export function generateTimeScaleHeaders(scale: string, minDate: string, maxDate: string): string[] { + const headers: string[] = []; + const start = new Date(minDate); + const end = new Date(maxDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) return headers; + const current = new Date(start); + switch (scale) { + case 'hour': + while (current <= end) { + headers.push(current.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' })); + current.setHours(current.getHours() + 1); + } + break; + case 'day': + while (current <= end) { + headers.push(current.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + current.setDate(current.getDate() + 1); + } + break; + case 'week': { + let week = 1; + while (current <= end) { + headers.push(`Week ${week++}`); + current.setDate(current.getDate() + 7); + } + break; + } + case 'quarter': + current.setMonth(Math.floor(current.getMonth() / 3) * 3, 1); + while (current <= end) { + headers.push(`Q${Math.floor(current.getMonth() / 3) + 1} ${current.getFullYear()}`); + current.setMonth(current.getMonth() + 3); + } + break; + case 'year': + // Snap to the calendar-year start so every year touched by the range + // gets a bucket (mirrors the quarter snap above). + current.setMonth(0, 1); + while (current <= end) { + headers.push(String(current.getFullYear())); + current.setFullYear(current.getFullYear() + 1); + } + break; + case 'month': + default: + while (current <= end) { + headers.push(current.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })); + current.setMonth(current.getMonth() + 1); + } + break; + } + return headers; +} // Helper function to calculate date range from items function calculateDateRange(items: any[]): { minDate: string; maxDate: string } { @@ -266,51 +346,14 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time const minDate = schema.minDate || dateRange.minDate; const maxDate = schema.maxDate || dateRange.maxDate; - // Generate time scale headers (months, weeks, etc.) - const timeScale = schema.timeScale || 'month'; - const generateTimeHeaders = () => { - const headers: string[] = []; - const start = new Date(minDate); - const end = new Date(maxDate); - - if (timeScale === 'month') { - const current = new Date(start); - while (current <= end) { - headers.push( - current.toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - }) - ); - current.setMonth(current.getMonth() + 1); - } - } else if (timeScale === 'week') { - const current = new Date(start); - while (current <= end) { - headers.push( - `Week ${Math.ceil( - (current.getTime() - start.getTime()) / MILLISECONDS_PER_WEEK - ) + 1}` - ); - current.setDate(current.getDate() + 7); - } - } else if (timeScale === 'day') { - const current = new Date(start); - while (current <= end) { - headers.push( - current.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - ); - current.setDate(current.getDate() + 1); - } - } - - return headers; - }; - - const timeHeaders = generateTimeHeaders(); + // Generate time scale headers — the spec `scale` key drives this + // (legacy `timeScale` kept for stored JSON); every spec scale produces + // a header row (#2942). + const timeHeaders = generateTimeScaleHeaders( + resolveTimelineScale(schema as { scale?: unknown; timeScale?: unknown }), + minDate, + maxDate, + ); return ( diff --git a/packages/providers/package.json b/packages/providers/package.json index f3a9b227f9..cdd96d35b1 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -37,6 +37,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@objectstack/spec": "^17.0.0-rc.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "typescript": "^6.0.3" diff --git a/packages/providers/src/ThemeProvider.tsx b/packages/providers/src/ThemeProvider.tsx index 1372c65991..47485c4a2f 100644 --- a/packages/providers/src/ThemeProvider.tsx +++ b/packages/providers/src/ThemeProvider.tsx @@ -30,7 +30,11 @@ export function ThemeProvider({ const root = window.document.documentElement; root.classList.remove('light', 'dark'); - if (theme === 'system') { + // The spec's OS-following mode is `auto` (ThemeModeSchema, ui/theme.zod.ts); + // `system` is this provider's pre-spec spelling, kept for stored values. + // Branching on `system` alone sent `auto` into `classList.add('auto')` — + // a class no Tailwind variant matches, locking the light theme (#2942). + if (theme === 'auto' || theme === 'system') { const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') .matches ? 'dark' diff --git a/packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx b/packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx new file mode 100644 index 0000000000..e822c99899 --- /dev/null +++ b/packages/providers/src/__tests__/theme-mode-spec-parity.test.tsx @@ -0,0 +1,88 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Theme mode ↔ spec vocabulary parity + behavior (#2942). + * + * The spec's OS-following mode is `auto` (`ThemeModeSchema`, + * `ui/theme.zod.ts`); this provider branched on its pre-spec `system` + * spelling only, so `mode: 'auto'` fell into `classList.add('auto')` — a + * class no Tailwind variant matches. The page locked to the light theme, + * the OS preference was ignored, and nothing errored. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { ThemeModeSchema } from '@objectstack/spec/ui'; +import { ThemeProvider } from '../ThemeProvider'; + +function mockMatchMedia(prefersDark: boolean) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: prefersDark, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + })), + }); +} + +describe('ThemeProvider covers the spec theme-mode vocabulary', () => { + beforeEach(() => { + document.documentElement.className = ''; + window.localStorage?.clear?.(); + mockMatchMedia(true); + }); + + it('reads a non-empty enum from the spec', () => { + const raw = (ThemeModeSchema as unknown as { options?: readonly string[] }).options; + expect(Array.isArray(raw) && raw.length > 0, 'could not read ThemeModeSchema.options').toBe(true); + }); + + it('every spec mode resolves to a real light/dark class — never a dead literal', () => { + const raw = (ThemeModeSchema as unknown as { options?: readonly string[] }).options ?? []; + for (const mode of raw) { + document.documentElement.className = ''; + const { unmount } = render( + +
+ , + ); + const classes = [...document.documentElement.classList]; + expect( + classes.some((c) => c === 'light' || c === 'dark'), + `mode '${mode}' must resolve to light/dark, got [${classes.join(', ')}]`, + ).toBe(true); + expect(classes, `mode '${mode}' leaked a dead class`).not.toContain(mode === 'auto' ? 'auto' : '__never__'); + unmount(); + } + }); + + it("'auto' follows the OS preference (dark here)", () => { + render( + +
+ , + ); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it("legacy 'system' keeps following the OS preference", () => { + render( + +
+ , + ); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); +}); diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index 07c7bb577e..36041f7dd3 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -10,7 +10,9 @@ export interface MetadataProviderProps { children: ReactNode; } -export type Theme = 'light' | 'dark' | 'system'; +// `auto` is the spec's OS-following mode (ThemeModeSchema); `system` is the +// pre-spec spelling, kept accepted for stored preferences. +export type Theme = 'light' | 'dark' | 'auto' | 'system'; export interface ThemeProviderProps { defaultTheme?: Theme; diff --git a/packages/react/src/context/NotificationContext.tsx b/packages/react/src/context/NotificationContext.tsx index 32198a7a71..6f923806ea 100644 --- a/packages/react/src/context/NotificationContext.tsx +++ b/packages/react/src/context/NotificationContext.tsx @@ -19,18 +19,59 @@ import React, { createContext, useCallback, useContext, useMemo, useState } from /** Notification severity levels aligned with NotificationSeveritySchema */ export type NotificationSeverityLevel = 'info' | 'success' | 'warning' | 'error'; -/** Notification display type aligned with NotificationTypeSchema */ -export type NotificationDisplayType = 'toast' | 'banner' | 'snackbar' | 'modal'; +/** + * Notification display type — the spec `NotificationTypeSchema` + * (`ui/notification.zod.ts`: toast / snackbar / banner / alert / inline). + * This union used to claim alignment while carrying a renderer-local `modal` + * and missing `alert` / `inline` (#2942); `modal` stays accepted as a + * deprecated legacy spelling for stored items. + */ +export type NotificationDisplayType = + | 'toast' + | 'snackbar' + | 'banner' + | 'alert' + | 'inline' + /** @deprecated renderer dialect — never in the spec; presented as `alert` */ + | 'modal'; -/** Notification position aligned with NotificationPositionSchema */ +/** + * Notification position — the spec `NotificationPositionSchema` + * (underscore spellings). The hyphen forms are this context's historical + * dialect, kept accepted for stored items. + */ export type NotificationPositionValue = + | 'top_left' + | 'top_center' + | 'top_right' + | 'bottom_left' + | 'bottom_center' + | 'bottom_right' + /** @deprecated legacy spelling — use `top_left` */ | 'top-left' + /** @deprecated legacy spelling — use `top_center` */ | 'top-center' + /** @deprecated legacy spelling — use `top_right` */ | 'top-right' + /** @deprecated legacy spelling — use `bottom_left` */ | 'bottom-left' + /** @deprecated legacy spelling — use `bottom_center` */ | 'bottom-center' + /** @deprecated legacy spelling — use `bottom_right` */ | 'bottom-right'; +/** + * The spec vocabularies this context implements — exported for the parity + * tests (#2942), which fail the moment `NotificationTypeSchema` / + * `NotificationPositionSchema` and these sets drift in either direction. + */ +export const SUPPORTED_NOTIFICATION_DISPLAY_TYPES: ReadonlySet = new Set([ + 'toast', 'snackbar', 'banner', 'alert', 'inline', +]); +export const SUPPORTED_NOTIFICATION_POSITIONS: ReadonlySet = new Set([ + 'top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right', +]); + /** Action button on a notification */ export interface NotificationActionButton { label: string; @@ -144,6 +185,11 @@ export const NotificationProvider: React.FC = ({ const id = `notification-${++notificationCounter}`; const notification: NotificationItem = { ...input, + // Materialize the declared presentation (spec default: toast; the + // legacy `modal` dialect presents as its nearest spec family, alert) + // so the delegate can branch on it — it used to be stored and never + // read anywhere (#2942). + displayType: input.displayType === 'modal' ? 'alert' : (input.displayType ?? 'toast'), id, createdAt: new Date(), read: false, diff --git a/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx b/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx new file mode 100644 index 0000000000..0856a3aac7 --- /dev/null +++ b/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx @@ -0,0 +1,134 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Animation / notification / navigation ↔ spec vocabulary parity (#2942). + * + * Four hyphen-vs-underscore (and coverage) drifts lived in this package: + * + * - `useAnimation`'s preset map was keyed in hyphens while the spec enum uses + * underscores, and `rotate`/`flip` were absent — 6 of 9 spec presets looked + * up NOTHING and rendered no animation; + * - its easing map had the same split, and the `EASING_MAP[easing] || easing` + * fallthrough emitted `animationTimingFunction: 'ease_in_out'` — invalid + * CSS the browser silently dropped; + * - `NotificationContext` carried a `modal` dialect, missed `alert`/`inline`, + * and keyed positions in hyphens — all under comments claiming alignment; + * - `useNavigationOverlay` read only the deprecated `width`, so an authored + * `size` bucket was ignored by every host except app-shell. + */ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { + TransitionPresetSchema, + EasingFunctionSchema, + NotificationTypeSchema, + NotificationPositionSchema, +} from '@objectstack/spec/ui'; +import { + useAnimation, + SUPPORTED_TRANSITION_PRESETS, + SUPPORTED_EASING_FUNCTIONS, +} from '../useAnimation'; +import { resolveOverlayWidth } from '../useNavigationOverlay'; +import { + SUPPORTED_NOTIFICATION_DISPLAY_TYPES, + SUPPORTED_NOTIFICATION_POSITIONS, +} from '../../context/NotificationContext'; + +function options(schema: unknown): string[] { + const raw = (schema as { options?: readonly string[] }).options; + return Array.isArray(raw) ? [...raw] : []; +} + +function assertParity(specNames: string[], implemented: ReadonlySet, what: string) { + expect(specNames, `could not read the ${what} enum from the spec`).not.toEqual([]); + expect( + specNames.filter((name) => !implemented.has(name)), + `spec ${what} values with no implementation`, + ).toEqual([]); + expect( + [...implemented].filter((name) => !specNames.includes(name)), + `renderer-local ${what} dialect — promote into @objectstack/spec instead`, + ).toEqual([]); +} + +describe('react hooks cover the spec animation/notification vocabularies', () => { + it('transition presets match TransitionPresetSchema both ways', () => { + assertParity(options(TransitionPresetSchema), SUPPORTED_TRANSITION_PRESETS, 'transition preset'); + }); + + it('easing functions match EasingFunctionSchema both ways', () => { + assertParity(options(EasingFunctionSchema), SUPPORTED_EASING_FUNCTIONS, 'easing'); + }); + + it('notification display types match NotificationTypeSchema both ways', () => { + assertParity(options(NotificationTypeSchema), SUPPORTED_NOTIFICATION_DISPLAY_TYPES, 'notification display type'); + }); + + it('notification positions match NotificationPositionSchema both ways', () => { + assertParity(options(NotificationPositionSchema), SUPPORTED_NOTIFICATION_POSITIONS, 'notification position'); + }); +}); + +describe('useAnimation renders every spec preset and easing', () => { + it('every spec preset except none resolves to non-empty classes', () => { + for (const preset of options(TransitionPresetSchema)) { + const { result } = renderHook(() => useAnimation({ preset: preset as never })); + if (preset === 'none') { + expect(result.current.className).toBe(''); + } else { + expect(result.current.className, `preset '${preset}' must animate`).not.toBe(''); + } + } + }); + + it('every spec easing resolves to valid CSS (never the raw underscore token)', () => { + for (const easing of options(EasingFunctionSchema)) { + const { result } = renderHook(() => useAnimation({ preset: 'fade', easing: easing as never })); + const value = result.current.style.animationTimingFunction; + expect(value, `easing '${easing}' must map to CSS`).toBeTruthy(); + expect(String(value), `easing '${easing}' leaked an invalid raw token`).not.toContain('_'); + } + }); + + it('legacy hyphen spellings keep animating (stored-config compatibility)', () => { + const { result } = renderHook(() => + useAnimation({ preset: 'slide-up' as never, easing: 'ease-in-out' as never }), + ); + expect(result.current.className).toContain('slide-in-from-bottom'); + expect(result.current.style.animationTimingFunction).toBe('ease-in-out'); + }); + + it('an out-of-vocabulary easing is dropped rather than emitted as invalid CSS', () => { + const { result } = renderHook(() => useAnimation({ preset: 'fade', easing: 'bouncy_town' as never })); + expect(result.current.style.animationTimingFunction).toBeUndefined(); + }); + + it('raw CSS timing functions still pass through', () => { + const { result } = renderHook(() => + useAnimation({ preset: 'fade', easing: 'cubic-bezier(0.1, 0.2, 0.3, 0.4)' as never }), + ); + expect(result.current.style.animationTimingFunction).toBe('cubic-bezier(0.1, 0.2, 0.3, 0.4)'); + }); +}); + +describe('useNavigationOverlay honors the spec size buckets', () => { + it('every explicit bucket resolves to a viewport-clamped width', () => { + for (const size of ['sm', 'md', 'lg', 'xl', 'full'] as const) { + const width = resolveOverlayWidth({ mode: 'drawer', size }); + expect(width, `size '${size}' must resolve a width`).toMatch(/^min\(92vw, \d+px\)$/); + } + }); + + it("an explicit width wins over the bucket; 'auto' stays host-derived", () => { + expect(resolveOverlayWidth({ mode: 'drawer', size: 'xl', width: 500 })).toBe(500); + expect(resolveOverlayWidth({ mode: 'drawer', size: 'auto' })).toBeUndefined(); + expect(resolveOverlayWidth({ mode: 'drawer' })).toBeUndefined(); + }); +}); diff --git a/packages/react/src/hooks/useAnimation.ts b/packages/react/src/hooks/useAnimation.ts index 85ece08911..6c1ccb7feb 100644 --- a/packages/react/src/hooks/useAnimation.ts +++ b/packages/react/src/hooks/useAnimation.ts @@ -11,25 +11,55 @@ import { useMemo } from 'react'; /** Animation trigger type */ export type AnimationTriggerType = 'enter' | 'exit' | 'hover' | 'focus' | 'click'; -/** Easing presets aligned with EasingFunctionSchema */ +/** + * Easing presets aligned with the spec `EasingFunctionSchema` + * (`ui/animation.zod.ts` — underscore spellings). The hyphen spellings are + * this hook's historical dialect, still accepted at runtime (normalized + * before lookup) so existing configs keep animating. + */ export type EasingPreset = | 'linear' | 'ease' + | 'ease_in' + | 'ease_out' + | 'ease_in_out' + | 'spring' + /** @deprecated legacy spelling — use `ease_in` */ | 'ease-in' + /** @deprecated legacy spelling — use `ease_out` */ | 'ease-out' - | 'ease-in-out' - | 'spring'; + /** @deprecated legacy spelling — use `ease_in_out` */ + | 'ease-in-out'; -/** Transition preset aligned with TransitionPresetSchema */ +/** + * Transition presets aligned with the spec `TransitionPresetSchema` + * (`ui/animation.zod.ts` — underscore spellings, including `rotate` and + * `flip`). The hyphen spellings and `scale-fade` are this hook's historical + * dialect, still accepted at runtime so existing configs keep animating — + * but the spec's own spellings used to look up NOTHING here (#2942): the + * map was keyed in hyphens, so `preset: 'slide_up'` validated and rendered + * no animation at all. + */ export type TransitionPresetType = | 'fade' + | 'slide_up' + | 'slide_down' + | 'slide_left' + | 'slide_right' + | 'scale' + | 'rotate' + | 'flip' + | 'none' + /** @deprecated legacy spelling — use `slide_up` */ | 'slide-up' + /** @deprecated legacy spelling — use `slide_down` */ | 'slide-down' + /** @deprecated legacy spelling — use `slide_left` */ | 'slide-left' + /** @deprecated legacy spelling — use `slide_right` */ | 'slide-right' - | 'scale' - | 'scale-fade' - | 'none'; + /** @deprecated legacy dialect (never in the spec) — use `flip` */ + | 'scale-fade'; export interface AnimationConfig { /** The animation/transition preset */ @@ -52,23 +82,50 @@ export interface AnimationResult { style: React.CSSProperties; } -const EASING_MAP: Record = { +/** + * The spec vocabularies this hook implements — exported for the parity tests, + * which fail the moment `TransitionPresetSchema` / `EasingFunctionSchema` and + * these sets drift in either direction (#2942). + */ +export const SUPPORTED_TRANSITION_PRESETS: ReadonlySet = new Set([ + 'fade', 'slide_up', 'slide_down', 'slide_left', 'slide_right', 'scale', 'rotate', 'flip', 'none', +]); +export const SUPPORTED_EASING_FUNCTIONS: ReadonlySet = new Set([ + 'linear', 'ease', 'ease_in', 'ease_out', 'ease_in_out', 'spring', +]); + +/** + * Normalize a legacy hyphen spelling (this hook's pre-#2942 dialect) onto the + * spec's underscore vocabulary. `scale-fade` never existed in the spec; its + * classes were identical to `flip`'s, so it maps there. + */ +function normalizePreset(preset: string): string { + if (preset === 'scale-fade') return 'flip'; + return preset.replace(/-/g, '_'); +} + +// Keyed by the SPEC spellings (`ui/animation.zod.ts`). Class values for the +// shared presets are unchanged; `rotate`/`flip` follow `usePageTransition`'s +// enter classes (spin-in / fade+zoom), the sibling hook that always carried +// the full spec vocabulary. +const EASING_MAP: Record = { linear: 'linear', ease: 'ease', - 'ease-in': 'ease-in', - 'ease-out': 'ease-out', - 'ease-in-out': 'ease-in-out', + ease_in: 'ease-in', + ease_out: 'ease-out', + ease_in_out: 'ease-in-out', spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)', }; -const PRESET_CLASSES: Record = { +const PRESET_CLASSES: Record = { fade: 'animate-in fade-in', - 'slide-up': 'animate-in slide-in-from-bottom-2', - 'slide-down': 'animate-in slide-in-from-top-2', - 'slide-left': 'animate-in slide-in-from-right-2', - 'slide-right': 'animate-in slide-in-from-left-2', + slide_up: 'animate-in slide-in-from-bottom-2', + slide_down: 'animate-in slide-in-from-top-2', + slide_left: 'animate-in slide-in-from-right-2', + slide_right: 'animate-in slide-in-from-left-2', scale: 'animate-in zoom-in-95', - 'scale-fade': 'animate-in fade-in zoom-in-95', + rotate: 'animate-in spin-in-90', + flip: 'animate-in fade-in zoom-in-95', none: '', }; @@ -99,7 +156,7 @@ export function useAnimation(config: AnimationConfig = {}): AnimationResult { return { className: '', style: {} }; } - const className = PRESET_CLASSES[preset] || ''; + const className = PRESET_CLASSES[normalizePreset(preset)] || ''; const style: React.CSSProperties = {}; if (duration !== undefined) { @@ -109,7 +166,12 @@ export function useAnimation(config: AnimationConfig = {}): AnimationResult { style.animationDelay = `${delay}ms`; } if (easing) { - style.animationTimingFunction = EASING_MAP[easing] || easing; + // Spec spellings (and legacy hyphen ones) resolve through the map; an + // out-of-vocabulary string only passes through when it is plausible raw + // CSS (`cubic-bezier(…)`, `steps(…)`) — `ease_in_out` reaching the DOM + // verbatim was an invalid declaration the browser silently dropped. + style.animationTimingFunction = + EASING_MAP[normalizePreset(easing)] ?? (easing.includes('(') ? easing : undefined); } return { className, style }; diff --git a/packages/react/src/hooks/useNavigationOverlay.ts b/packages/react/src/hooks/useNavigationOverlay.ts index 76c5689aad..2914096512 100644 --- a/packages/react/src/hooks/useNavigationOverlay.ts +++ b/packages/react/src/hooks/useNavigationOverlay.ts @@ -29,9 +29,45 @@ export interface NavigationConfig { view?: string; preventNavigation?: boolean; openNewTab?: boolean; + /** Spec `NavigationConfig.size` (#2578) — coarse overlay bucket. */ + size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; + /** @deprecated [#2578 → `size`] explicit pixel/percent width. */ width?: string | number; } +/** + * Pixel cap per overlay `size` bucket, clamped to the viewport at render — + * mirrors `plugin-view/src/recordSurface.ts` (`OVERLAY_SIZE_PX`), which owns + * the `size: 'auto'` field-count derivation this layer cannot perform (no + * object schema here). Kept in lockstep by the spec-parity test. + * + * Before #2942 this hook only read the deprecated `width`, so an authored + * `size` bucket was silently ignored by every host except app-shell (which + * pre-resolves it before calling in). + */ +const OVERLAY_SIZE_WIDTHS: Record<'sm' | 'md' | 'lg' | 'xl' | 'full', string> = { + sm: 'min(92vw, 480px)', + md: 'min(92vw, 720px)', + lg: 'min(92vw, 960px)', + xl: 'min(92vw, 1200px)', + full: 'min(92vw, 1600px)', +}; + +/** + * Resolve the overlay width from a NavigationConfig: an explicit `width` wins + * (app-shell pre-resolves `size` into it); otherwise a declared bucket maps + * through {@link OVERLAY_SIZE_WIDTHS}. `'auto'`/absent stays `undefined` — the + * host's default width — because deriving `auto` needs the object's field + * count, which only schema-aware hosts have. Exported for the parity test. + */ +export function resolveOverlayWidth(navigation: NavigationConfig | undefined): string | number | undefined { + if (!navigation) return undefined; + if (navigation.width !== undefined) return navigation.width; + const size = navigation.size; + if (size && size !== 'auto') return OVERLAY_SIZE_WIDTHS[size]; + return undefined; +} + export type NavigationMode = NavigationConfig['mode']; export interface UseNavigationOverlayOptions { @@ -124,7 +160,7 @@ export function useNavigationOverlay( const [selectedRecord, setSelectedRecord] = useState | null>(null); const mode: NavigationMode = navigation?.mode ?? 'page'; - const width = navigation?.width; + const width = resolveOverlayWidth(navigation); const view = navigation?.view; const isOverlay = mode === 'drawer' || mode === 'modal' || mode === 'split' || mode === 'popover'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 33adae117d..9d01d8eab7 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -255,6 +255,10 @@ const UserFilterTabSchema = z * User Filters Configuration Schema (Airtable Interfaces-style) */ const UserFiltersSchema = z.object({ + // AUTHORING contract (ADR-0053): `toggle` is deliberately not authorable — + // new configs can only write dropdown/tabs. The RENDERER still honors + // stored `toggle` metadata (spec ADR-0047 §3.4a keeps it in ITS enum "so + // existing configs keep rendering") — see plugin-list `UserFilters`. element: z.enum(['dropdown', 'tabs']).describe('UI element type'), fields: z.array(UserFilterFieldSchema).optional().describe('Field-level filters'), tabs: z.array(UserFilterTabSchema).optional().describe('Named filter presets'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6f48b3af..2a48e9df10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1201,6 +1201,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@objectstack/spec': + specifier: ^17.0.0-rc.0 + version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) '@types/react': specifier: 19.2.17 version: 19.2.17 @@ -1300,6 +1303,9 @@ importers: specifier: workspace:* version: link:../types devDependencies: + '@objectstack/spec': + specifier: ^17.0.0-rc.0 + version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) '@types/react': specifier: 19.2.17 version: 19.2.17 @@ -1472,6 +1478,9 @@ importers: specifier: ^3.10.1 version: 3.10.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.6)(react@19.2.8)(redux@5.0.1) devDependencies: + '@objectstack/spec': + specifier: ^17.0.0-rc.0 + version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) '@types/react': specifier: 19.2.17 version: 19.2.17 @@ -2500,6 +2509,9 @@ importers: specifier: 19.2.8 version: 19.2.8(react@19.2.8) devDependencies: + '@objectstack/spec': + specifier: ^17.0.0-rc.0 + version: 17.0.0-rc.0(ai@7.0.37(zod@4.4.3)) '@types/react': specifier: 19.2.17 version: 19.2.17