Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/date-range-default-range-binding-4984.md
Original file line number Diff line number Diff line change
@@ -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<DashboardComponentSchema, 'globalFilters' |
'dateRange'>`, 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).
109 changes: 109 additions & 0 deletions packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => {
const node = RANGE_NS.reduce<unknown>(
(acc, seg) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[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<string, unknown>;
};

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([]);
});
});
21 changes: 18 additions & 3 deletions packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
};
/**
Expand Down
Loading