diff --git a/.changeset/date-range-default-range-binding-4984.md b/.changeset/date-range-default-range-binding-4984.md new file mode 100644 index 000000000..60e729853 --- /dev/null +++ b/.changeset/date-range-default-range-binding-4984.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': patch +--- + +`DashboardComponentSchema.dateRange.defaultRange` is now bound to +`DateRangeDefaultRange` from `@objectstack/spec/ui` instead of restating it as a +hand-written 14-member union (objectui#4984). + +The union was byte-faithful to the spec — all 14 members, same order — so nothing +a user hits changes today. What was missing is the tie that keeps it faithful: +`resolveDashboardFilterDefs` takes `Pick`, so this union is what typechecks every TS-constructed dashboard, and +a preset the spec ADDS would have been a legal document that objectui's own types +said could not exist — the "narrower than the contract it implements" shape whose +consequence in objectui#4163 was that the bad reads were invisible to `tsc`. + +No gate reported it: `check:spec-symbols` rule 1 matches by NAME and an inline union +on an interface member has no symbol to collide with, while rule 2's claim heuristic +was waved through by the `SpecGlobalFilter` reference a few lines above. Binding makes +the file's existing "Aligned with @objectstack/spec" comment structural rather than +prose. + +The emitted `.d.ts` collapses the inline union to the imported alias; the published +type surface is unchanged — measured with the TypeScript checker over the emitted +declarations (679 reachable exports from `dist/index.d.ts`, 22 from `dist/complex.d.ts`, +and `defaultRange` resolving to the same 14 string-literal members before and after). diff --git a/packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts b/packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts new file mode 100644 index 000000000..48cdf006a --- /dev/null +++ b/packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts @@ -0,0 +1,109 @@ +/** + * 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. + */ + +/** + * objectui#4984 item 2 — the LABEL half of the date-range preset fan-out. + * + * objectui#4167 made `@object-ui/core`'s `DATE_RANGE_PRESETS` the spec's own + * array by reference, so a preset `@objectstack/spec` ADDS arrives in the + * dashboard filter dropdown for free. Two things then have to follow it, and + * only one of them was pinned: + * + * - BOUNDS — `packages/core/src/utils/__tests__/dashboard-filters.test.ts` + * ("every offered preset resolves to date-macro bounds") already asserts a + * preset with no entry in the bounds table is caught. That is the half with + * teeth at runtime. + * - LABELS — nothing. `DashboardFilterBar.tsx` builds the key dynamically + * — the key is `dashboard.filters.range.` followed by the member name, and + * the fallback it degrades to is `p.replace(/_/g, ' ')` — so + * `check:i18n-keys` cannot see a template-literal key, and the call site + * degrades SILENTLY: the new preset renders as its own member name with the + * underscores swapped for spaces — `next_week` as "next week", in Chinese, + * Japanese and Arabic alike — with every gate green. + * + * So the one class of change the vocabulary extraction was designed to make + * painless is also the one that would ship untranslated in ten locales. This + * file is that missing pin, and it is deliberately the mirror of the bounds-half + * test: same source of truth (`DATE_RANGE_PRESETS`, not a copy of it), same + * "every offered preset …" shape. + * + * ── Scope: `en` only, on purpose ───────────────────────────────────────────── + * `en` is where the vocabulary/label tie lives — it is the pack every other one + * is backfilled from and the one `fallbackLng: 'en'` degrades to. The 10-locale + * spread is the i18n channel's existing business: `en-zh-key-parity.test.ts` + * carries `zh` off this pin for free, and the remaining eight packs are tracked + * under objectui#2872 part a. + * + * ── This file lives in `@object-ui/i18n`, not in core ──────────────────────── + * The claim spans the vocabulary and the locale packs. `@object-ui/i18n` + * depends on `@object-ui/core`, so it can reach both; core cannot reach the + * packs without inverting that edge. `@objectstack/spec` is deliberately NOT + * imported here — it is not a dependency of this package, and reading the + * vocabulary through `@object-ui/core` is the same reference anyway (pinned by + * `toBe` in the bounds-half file). + * + * ── PREDICTIONS, written before the run ────────────────────────────────────── + * GREEN on `main` today: the vocabulary is byte-faithful — 13 presets, 13 `en` + * labels. That is exactly why a pin here is cheap now, and exactly why a green + * run proves nothing on its own. The discriminating evidence is the mutation + * legs recorded in the PR, which drive each assertion RED on its own: + * - delete one `en` key → "every preset has an `en` label" RED + * - add a label with no preset → "no orphan labels" RED + */ +import { describe, it, expect } from 'vitest'; +import { DATE_RANGE_PRESETS } from '@object-ui/core'; +import { builtInLocales } from '../locales'; + +/** + * The namespace `DashboardFilterBar.tsx` builds its key under. Written out + * because the call site's key is a template literal — no static analysis + * connects the two, which is the whole reason this file exists. + */ +const RANGE_NS = ['dashboard', 'filters', 'range'] as const; + +const rangeLabels = (): Record => { + const node = RANGE_NS.reduce( + (acc, seg) => (acc && typeof acc === 'object' ? (acc as Record)[seg] : undefined), + builtInLocales.en, + ); + if (!node || typeof node !== 'object') { + throw new Error(`en.${RANGE_NS.join('.')} is missing — the label table moved or was renamed`); + } + return node as Record; +}; + +describe('dashboard date-range preset labels (en) track DATE_RANGE_PRESETS', () => { + it('reads a non-empty vocabulary and a non-empty label table', () => { + // Guards the vacuous pass: if either side resolved to nothing, every + // assertion below would iterate zero times and report green. The counts are + // NOT pinned to today's 13 — pinning the number would re-create the very + // hand-maintained copy this file exists to retire. + expect(DATE_RANGE_PRESETS.length).toBeGreaterThan(5); + expect(Object.keys(rangeLabels()).length).toBeGreaterThan(5); + }); + + it('every offered preset has an `en` label', () => { + // A preset with no key here is not a missing translation — it is a dropdown + // item rendered as its own member name (`last_90_days` → "last 90 days") in + // every locale at once, because the call site's `defaultValue` fallback is + // the member name with underscores replaced. + const labels = rangeLabels(); + const missing = DATE_RANGE_PRESETS.filter((p) => typeof labels[p] !== 'string' || !(labels[p] as string).trim()); + expect(missing).toEqual([]); + }); + + it('has no orphan label left behind by a preset the spec removed', () => { + // The other direction of the same tie. `custom` is deliberately not a + // member of DATE_RANGE_PRESETS and is not labelled here either — it has its + // own sibling key, `dashboard.filters.custom`. + const orphans = Object.keys(rangeLabels()).filter( + (k) => !(DATE_RANGE_PRESETS as readonly string[]).includes(k), + ); + expect(orphans).toEqual([]); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 8caac21d6..24af60d12 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -17,6 +17,7 @@ import type { DashboardWidget as SpecDashboardWidget, + DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; @@ -784,12 +785,26 @@ export interface DashboardComponentSchema extends BaseSchema { /** * Date range filter configuration. * Aligned with @objectstack/spec DashboardSchema.dateRange. + * + * `defaultRange` is BOUND to the spec's `DateRangeDefaultRange` rather than + * restated (objectui#4984). It used to be a hand-written 14-member union — + * byte-faithful to the spec, but faithful only until the next spec release: + * a preset the spec ADDS would be a legal document that objectui's own types + * say cannot exist, the same "narrower than the contract it implements" shape + * as objectui#4163's `label`, whose consequence was that the bad reads were + * invisible to `tsc`. No gate could report the drift either — `check:spec-symbols` + * rule 1 matches by NAME and an inline union on an interface member has no + * symbol to collide with, while rule 2's claim heuristic was waved through by + * the `SpecGlobalFilter` reference a few lines up. Binding makes the "Aligned + * with" line above structural instead of prose. + * + * `DATE_RANGE_DEFAULT_RANGES` is `[...DATE_RANGE_PRESETS, 'custom']`, so this + * tracks the same vocabulary `@object-ui/core` re-exports by reference + * (objectui#4167) — one list, reached two ways. */ dateRange?: { field?: string; - defaultRange?: 'today' | 'yesterday' | 'this_week' | 'last_week' | 'this_month' | 'last_month' - | 'this_quarter' | 'last_quarter' | 'this_year' | 'last_year' - | 'last_7_days' | 'last_30_days' | 'last_90_days' | 'custom'; + defaultRange?: SpecDateRangeDefaultRange; allowCustomRange?: boolean; }; /**