basalt-ui 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
package/src/index.ts
CHANGED
|
@@ -99,9 +99,11 @@ export {
|
|
|
99
99
|
type AnyField,
|
|
100
100
|
type BooleanField,
|
|
101
101
|
type EnumField,
|
|
102
|
+
type FieldFallback,
|
|
102
103
|
type FieldHandle,
|
|
103
104
|
type FieldLane,
|
|
104
105
|
type FieldOption,
|
|
106
|
+
type FieldSetOptions,
|
|
105
107
|
type FieldValue,
|
|
106
108
|
type MultiField,
|
|
107
109
|
type NumberField,
|
|
@@ -109,6 +111,8 @@ export {
|
|
|
109
111
|
type RangeParams,
|
|
110
112
|
type RangePresets,
|
|
111
113
|
type RangeValue,
|
|
114
|
+
type RangeWindow,
|
|
115
|
+
type RangeWindows,
|
|
112
116
|
type ResolvedLane,
|
|
113
117
|
type SearchValues,
|
|
114
118
|
type StoredValues,
|
|
@@ -126,6 +130,7 @@ export {
|
|
|
126
130
|
DeltaBadge,
|
|
127
131
|
type DeltaBadgeProps,
|
|
128
132
|
StatCard,
|
|
133
|
+
type StatCardBreakdownRow,
|
|
129
134
|
type StatCardProps,
|
|
130
135
|
type StatCardTone,
|
|
131
136
|
EmptyState,
|
|
@@ -14,15 +14,19 @@ export type {
|
|
|
14
14
|
AnyField,
|
|
15
15
|
BooleanField,
|
|
16
16
|
EnumField,
|
|
17
|
+
FieldFallback,
|
|
17
18
|
FieldHandle,
|
|
18
19
|
FieldLane,
|
|
19
20
|
FieldOption,
|
|
21
|
+
FieldSetOptions,
|
|
20
22
|
FieldValue,
|
|
21
23
|
MultiField,
|
|
22
24
|
NumberField,
|
|
23
25
|
RangeField,
|
|
24
26
|
RangeParams,
|
|
25
27
|
RangePresets,
|
|
28
|
+
RangeWindow,
|
|
29
|
+
RangeWindows,
|
|
26
30
|
RangeValue,
|
|
27
31
|
ResolvedLane,
|
|
28
32
|
SearchValues,
|
|
@@ -14,9 +14,11 @@ export type {
|
|
|
14
14
|
AnyField,
|
|
15
15
|
BooleanField,
|
|
16
16
|
EnumField,
|
|
17
|
+
FieldFallback,
|
|
17
18
|
FieldHandle,
|
|
18
19
|
FieldLane,
|
|
19
20
|
FieldOption,
|
|
21
|
+
FieldSetOptions,
|
|
20
22
|
FieldValue,
|
|
21
23
|
MultiField,
|
|
22
24
|
NumberField,
|
|
@@ -24,6 +26,8 @@ export type {
|
|
|
24
26
|
RangeParams,
|
|
25
27
|
RangePresets,
|
|
26
28
|
RangeValue,
|
|
29
|
+
RangeWindow,
|
|
30
|
+
RangeWindows,
|
|
27
31
|
ResolvedLane,
|
|
28
32
|
SearchValues,
|
|
29
33
|
StoredValues,
|
|
@@ -17,11 +17,11 @@ import {
|
|
|
17
17
|
createRoute,
|
|
18
18
|
createRouter,
|
|
19
19
|
} from '@tanstack/react-router'
|
|
20
|
-
import type { AnyField, FieldHandle } from '../state'
|
|
20
|
+
import type { AnyField, FieldHandle, FieldSetOptions } from '../state'
|
|
21
21
|
import { field } from './field'
|
|
22
22
|
import { createSearchStore } from './search-store'
|
|
23
23
|
|
|
24
|
-
type Probe = { value: unknown; set: (next: unknown) => void }
|
|
24
|
+
type Probe = { value: unknown; set: (next: unknown, opts?: FieldSetOptions) => void }
|
|
25
25
|
type Sink = { current: Probe | null }
|
|
26
26
|
|
|
27
27
|
function sink(): Sink {
|
|
@@ -32,7 +32,7 @@ function sink(): Sink {
|
|
|
32
32
|
function fieldProbe<F extends AnyField>(handle: FieldHandle<F>, into: Sink) {
|
|
33
33
|
return function FieldProbe(): ReactNode {
|
|
34
34
|
const [value, set] = handle.use()
|
|
35
|
-
into.current = { value, set: set as
|
|
35
|
+
into.current = { value, set: set as Probe['set'] }
|
|
36
36
|
return null
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -419,7 +419,7 @@ describe('store hooks', () => {
|
|
|
419
419
|
expect(count).toBe(2)
|
|
420
420
|
})
|
|
421
421
|
|
|
422
|
-
test('useReset returns every field to its fallback in ONE navigate call', async () => {
|
|
422
|
+
test('useReset returns every field to its fallback in ONE navigate call, UNSETTING the mirror', async () => {
|
|
423
423
|
const store = createSearchStore({
|
|
424
424
|
key: 'r-reset',
|
|
425
425
|
fields: {
|
|
@@ -451,14 +451,79 @@ describe('store hooks', () => {
|
|
|
451
451
|
})
|
|
452
452
|
expect(currentSearch(router)['compare']).toBe('none')
|
|
453
453
|
expect(currentSearch(router)['from']).toBeUndefined()
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
})
|
|
454
|
+
// Reset means UNSET: the persisted keys are DELETED, not overwritten with the resolved
|
|
455
|
+
// fallback — writing it would pin a thunk fallback into the mirror (see the lazy-fallback test
|
|
456
|
+
// below) and leave the field counted as active tomorrow.
|
|
457
|
+
expect(storedRecord('r-reset')).toEqual({})
|
|
459
458
|
navigate.mockRestore()
|
|
460
459
|
})
|
|
461
460
|
|
|
461
|
+
test('useReset UNSETS a thunk fallback rather than pinning it into the mirror', async () => {
|
|
462
|
+
let today = '2026-08-27'
|
|
463
|
+
const store = createSearchStore({
|
|
464
|
+
key: 'r-lazy-reset',
|
|
465
|
+
fields: {
|
|
466
|
+
range: field.enum(['1d', '7d'], '7d'),
|
|
467
|
+
since: field.string({ fallback: () => today }, { url: false }),
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
persist('r-lazy-reset', { since: '2026-01-01' })
|
|
471
|
+
const probe = sink()
|
|
472
|
+
let reset: (() => void) | null = null
|
|
473
|
+
let count = -1
|
|
474
|
+
|
|
475
|
+
await mountApp({
|
|
476
|
+
validateSearch: store.validateSearch,
|
|
477
|
+
entry: '/dashboard',
|
|
478
|
+
Dashboard: () => {
|
|
479
|
+
reset = store.useReset()
|
|
480
|
+
count = store.useActiveCount()
|
|
481
|
+
return fieldProbe(store.field.since, probe)()
|
|
482
|
+
},
|
|
483
|
+
})
|
|
484
|
+
expect(probe.current?.value).toBe('2026-01-01')
|
|
485
|
+
|
|
486
|
+
await act(async () => {
|
|
487
|
+
reset?.()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
expect(probe.current?.value).toBe('2026-08-27')
|
|
491
|
+
expect(storedRecord('r-lazy-reset')).toEqual({})
|
|
492
|
+
expect(count).toBe(0)
|
|
493
|
+
|
|
494
|
+
// The next day the same field reads TODAY, not the day the reset was pressed — which is the
|
|
495
|
+
// whole point of a thunk fallback, and what writing the resolved value took away.
|
|
496
|
+
today = '2026-08-28'
|
|
497
|
+
expect(store.field.since.fallback).toBe('2026-08-28')
|
|
498
|
+
expect(store.readStored()['since']).toBeUndefined()
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('field.clear() returns the URL param to the fallback and drops the mirror key', async () => {
|
|
502
|
+
const store = createSearchStore({
|
|
503
|
+
key: 'r-clear-url',
|
|
504
|
+
fields: { range: field.enum(['1d', '7d', '30d'], '7d') },
|
|
505
|
+
})
|
|
506
|
+
persist('r-clear-url', { range: '30d' })
|
|
507
|
+
const probe = sink()
|
|
508
|
+
|
|
509
|
+
const router = await mountApp({
|
|
510
|
+
validateSearch: store.validateSearch,
|
|
511
|
+
entry: '/dashboard?range=30d',
|
|
512
|
+
Dashboard: fieldProbe(store.field.range, probe),
|
|
513
|
+
})
|
|
514
|
+
expect(probe.current?.value).toBe('30d')
|
|
515
|
+
|
|
516
|
+
await act(async () => {
|
|
517
|
+
store.field.range.clear()
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
await waitFor(() => {
|
|
521
|
+
expect(currentSearch(router)['range']).toBe('7d')
|
|
522
|
+
})
|
|
523
|
+
expect(storedRecord('r-clear-url')).toEqual({})
|
|
524
|
+
expect(probe.current?.value).toBe('7d')
|
|
525
|
+
})
|
|
526
|
+
|
|
462
527
|
test('useReset from a FOREIGN route persists only — it never navigates (A1)', async () => {
|
|
463
528
|
const store = createSearchStore({
|
|
464
529
|
key: 'r-reset-foreign',
|
|
@@ -636,8 +701,31 @@ describe('the memory-only lane — url: false, persist: false', () => {
|
|
|
636
701
|
})
|
|
637
702
|
expect(probe.current?.value).toBe('a')
|
|
638
703
|
expect(count).toBe(0)
|
|
639
|
-
//
|
|
640
|
-
|
|
704
|
+
// Nothing was ever persisted, so the reset had no key to drop and wrote no envelope — the
|
|
705
|
+
// mirror only ever holds values a control produced.
|
|
706
|
+
expect(storedRecord('r-memory')).toBeNull()
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
test('field.clear() drops the memory value, and only that one', async () => {
|
|
710
|
+
const store = memoryStore()
|
|
711
|
+
const probe = sink()
|
|
712
|
+
|
|
713
|
+
await mountApp({
|
|
714
|
+
validateSearch: store.validateSearch,
|
|
715
|
+
entry: '/dashboard',
|
|
716
|
+
Dashboard: fieldProbe(store.field.scratch, probe),
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
await act(async () => {
|
|
720
|
+
probe.current?.set('c')
|
|
721
|
+
})
|
|
722
|
+
expect(probe.current?.value).toBe('c')
|
|
723
|
+
|
|
724
|
+
await act(async () => {
|
|
725
|
+
store.field.scratch.clear()
|
|
726
|
+
})
|
|
727
|
+
expect(probe.current?.value).toBe('a')
|
|
728
|
+
expect(storedRecord('r-memory')).toBeNull()
|
|
641
729
|
})
|
|
642
730
|
|
|
643
731
|
test('two mounts of the same field see one value', async () => {
|
|
@@ -659,3 +747,136 @@ describe('the memory-only lane — url: false, persist: false', () => {
|
|
|
659
747
|
expect(b.current?.value).toBe('b')
|
|
660
748
|
})
|
|
661
749
|
})
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* `set(next, { patch })` — the sibling params a page owns and the store does not. Clearing one used
|
|
753
|
+
* to need a second `navigate` beside the setter, which either raced the field's own write or left
|
|
754
|
+
* two history entries where the reader made one change.
|
|
755
|
+
*/
|
|
756
|
+
describe('use() — a patched write', () => {
|
|
757
|
+
const store = createSearchStore({
|
|
758
|
+
key: 'r-patch',
|
|
759
|
+
fields: { tab: field.enum(['overview', 'detail'], 'overview') },
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
/** The store's params PLUS one the page owns — the shape a real route composes. */
|
|
763
|
+
const validateSearch = (raw: Record<string, unknown>): Record<string, unknown> => ({
|
|
764
|
+
...store.validateSearch(raw),
|
|
765
|
+
detailDate: typeof raw['detailDate'] === 'string' ? raw['detailDate'] : undefined,
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
test('clears a sibling param the store does not own, in the same navigate', async () => {
|
|
769
|
+
const probe = sink()
|
|
770
|
+
const router = await mountApp({
|
|
771
|
+
validateSearch,
|
|
772
|
+
entry: '/dashboard?tab=detail&detailDate=2026-03-01',
|
|
773
|
+
Dashboard: fieldProbe(store.field.tab, probe),
|
|
774
|
+
})
|
|
775
|
+
expect(currentSearch(router)['detailDate']).toBe('2026-03-01')
|
|
776
|
+
|
|
777
|
+
await act(async () => {
|
|
778
|
+
probe.current?.set('overview', { patch: { detailDate: undefined } })
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
await waitFor(() => {
|
|
782
|
+
expect(currentSearch(router)['tab']).toBe('overview')
|
|
783
|
+
})
|
|
784
|
+
expect(currentSearch(router)['detailDate']).toBeUndefined()
|
|
785
|
+
expect(probe.current?.value).toBe('overview')
|
|
786
|
+
// The field's own lane is untouched by the patch — the write still mirrors.
|
|
787
|
+
expect(storedRecord('r-patch')).toEqual({ tab: 'overview' })
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
test('a patch cannot overwrite the value being set', async () => {
|
|
791
|
+
const probe = sink()
|
|
792
|
+
const router = await mountApp({
|
|
793
|
+
validateSearch,
|
|
794
|
+
entry: '/dashboard?tab=overview',
|
|
795
|
+
Dashboard: fieldProbe(store.field.tab, probe),
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
await act(async () => {
|
|
799
|
+
probe.current?.set('detail', { patch: { tab: 'overview', detailDate: '2026-04-01' } })
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
await waitFor(() => {
|
|
803
|
+
expect(currentSearch(router)['tab']).toBe('detail')
|
|
804
|
+
})
|
|
805
|
+
expect(currentSearch(router)['detailDate']).toBe('2026-04-01')
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
test('a patch naming a param ANOTHER field owns is refused, not silently desynced', async () => {
|
|
809
|
+
const twoFields = createSearchStore({
|
|
810
|
+
key: 'r-patch-owned',
|
|
811
|
+
fields: {
|
|
812
|
+
tab: field.enum(['overview', 'detail'], 'overview'),
|
|
813
|
+
range: field.enum(['7d', '30d'], '30d'),
|
|
814
|
+
},
|
|
815
|
+
})
|
|
816
|
+
const probe = sink()
|
|
817
|
+
const router = await mountApp({
|
|
818
|
+
validateSearch: twoFields.validateSearch,
|
|
819
|
+
entry: '/dashboard',
|
|
820
|
+
Dashboard: fieldProbe(twoFields.field.tab, probe),
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
// The URL would take `range=7d` while the mirror kept `30d`, so the next paramless visit and
|
|
824
|
+
// every `linkSearch` link would disagree with the page the write happened on.
|
|
825
|
+
expect(() => {
|
|
826
|
+
probe.current?.set('detail', { patch: { range: '7d' } })
|
|
827
|
+
}).toThrow(/field 'range' owns/)
|
|
828
|
+
expect(currentSearch(router)['tab']).toBe('overview')
|
|
829
|
+
expect(currentSearch(router)['range']).toBe('30d')
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
test('a patched write from a FOREIGN route drops the patch, and warns once', async () => {
|
|
833
|
+
const store = createSearchStore({
|
|
834
|
+
key: 'r-patch-foreign',
|
|
835
|
+
fields: { tab: field.enum(['overview', 'detail'], 'overview') },
|
|
836
|
+
})
|
|
837
|
+
const probe = sink()
|
|
838
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
839
|
+
|
|
840
|
+
const router = await mountApp({
|
|
841
|
+
validateSearch: store.validateSearch,
|
|
842
|
+
entry: '/other?detailDate=2026-03-01',
|
|
843
|
+
Other: fieldProbe(store.field.tab, probe),
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
await act(async () => {
|
|
847
|
+
probe.current?.set('detail', { patch: { detailDate: undefined } })
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
// The write persisted (A1); the patch had no navigate to merge into and is gone.
|
|
851
|
+
expect(storedRecord('r-patch-foreign')).toEqual({ tab: 'detail' })
|
|
852
|
+
expect(currentSearch(router)['detailDate']).toBe('2026-03-01')
|
|
853
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
854
|
+
|
|
855
|
+
await act(async () => {
|
|
856
|
+
probe.current?.set('overview', { patch: { detailDate: undefined } })
|
|
857
|
+
})
|
|
858
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
859
|
+
warn.mockRestore()
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
test('a field with no URL lane has no navigate to patch, and does not make one', async () => {
|
|
863
|
+
const local = createSearchStore({
|
|
864
|
+
key: 'r-patch-local',
|
|
865
|
+
fields: { compact: field.boolean(false, { url: false }) },
|
|
866
|
+
})
|
|
867
|
+
const probe = sink()
|
|
868
|
+
const router = await mountApp({
|
|
869
|
+
validateSearch,
|
|
870
|
+
entry: '/dashboard?tab=overview',
|
|
871
|
+
Dashboard: fieldProbe(local.field.compact, probe),
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
await act(async () => {
|
|
875
|
+
probe.current?.set(true, { patch: { detailDate: '2026-05-01' } })
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
expect(probe.current?.value).toBe(true)
|
|
879
|
+
expect(currentSearch(router)['detailDate']).toBeUndefined()
|
|
880
|
+
expect(storedRecord('r-patch-local')).toEqual({ compact: true })
|
|
881
|
+
})
|
|
882
|
+
})
|
|
@@ -396,6 +396,78 @@ describe('the handle, outside React', () => {
|
|
|
396
396
|
expect(store.field.range.toWindow({ preset: 'custom' })).toEqual({ window: 'custom' })
|
|
397
397
|
})
|
|
398
398
|
|
|
399
|
+
test('a preset declared with a `window` resolver projects to `{ from, to }`', () => {
|
|
400
|
+
// The derived presets three consumers hand-rolled beside the store (`3m`, `ytd`): one preset in
|
|
401
|
+
// the URL, two dates at the API boundary, no `presetToParams` in the page.
|
|
402
|
+
const seen: Date[] = []
|
|
403
|
+
const store = createSearchStore({
|
|
404
|
+
key: 'k-window-resolver',
|
|
405
|
+
fields: {
|
|
406
|
+
range: field.range({
|
|
407
|
+
presets: ['7d', '3m', 'ytd'],
|
|
408
|
+
fallback: '7d',
|
|
409
|
+
custom: true,
|
|
410
|
+
window: {
|
|
411
|
+
'3m': (now) => {
|
|
412
|
+
seen.push(now)
|
|
413
|
+
return { from: iso(addMonths(now, -3)), to: iso(now) }
|
|
414
|
+
},
|
|
415
|
+
ytd: (now) => ({ from: `${now.getUTCFullYear()}-01-01`, to: iso(now) }),
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
const now = new Date()
|
|
422
|
+
expect(store.field.range.toWindow({ preset: '3m' })).toEqual({
|
|
423
|
+
from: iso(addMonths(now, -3)),
|
|
424
|
+
to: iso(now),
|
|
425
|
+
})
|
|
426
|
+
expect(store.field.range.toWindow({ preset: 'ytd' })).toEqual({
|
|
427
|
+
from: `${now.getUTCFullYear()}-01-01`,
|
|
428
|
+
to: iso(now),
|
|
429
|
+
})
|
|
430
|
+
// Resolved at CALL time, with the current date — never captured at definition.
|
|
431
|
+
expect(seen[0] instanceof Date).toBe(true)
|
|
432
|
+
|
|
433
|
+
// A preset WITHOUT a resolver is untouched, in the same field.
|
|
434
|
+
expect(store.field.range.toWindow({ preset: '7d' })).toEqual({ window: '7d' })
|
|
435
|
+
// And a custom window still wins over any resolver.
|
|
436
|
+
expect(
|
|
437
|
+
store.field.range.toWindow({ preset: 'custom', from: '2026-03-01', to: '2026-03-14' }),
|
|
438
|
+
).toEqual({ from: '2026-03-01', to: '2026-03-14' })
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
test('a resolver changes nothing about the URL — the preset is still what is stored', () => {
|
|
442
|
+
const store = createSearchStore({
|
|
443
|
+
key: 'k-window-url',
|
|
444
|
+
fields: {
|
|
445
|
+
range: field.range({
|
|
446
|
+
presets: ['7d', '3m'],
|
|
447
|
+
fallback: '7d',
|
|
448
|
+
window: { '3m': () => ({ from: '2026-01-01', to: '2026-04-01' }) },
|
|
449
|
+
}),
|
|
450
|
+
},
|
|
451
|
+
})
|
|
452
|
+
expect(store.validateSearch({ range: '3m' })).toEqual({
|
|
453
|
+
range: '3m',
|
|
454
|
+
from: undefined,
|
|
455
|
+
to: undefined,
|
|
456
|
+
})
|
|
457
|
+
expect(store.field.range.toWindow({ preset: '3m' })).toEqual({
|
|
458
|
+
from: '2026-01-01',
|
|
459
|
+
to: '2026-04-01',
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('a preset named like an Object.prototype member is not a resolver', () => {
|
|
464
|
+
const store = createSearchStore({
|
|
465
|
+
key: 'k-window-proto',
|
|
466
|
+
fields: { range: field.range({ presets: ['toString', '7d'], fallback: '7d' }) },
|
|
467
|
+
})
|
|
468
|
+
expect(store.field.range.toWindow({ preset: 'toString' })).toEqual({ window: 'toString' })
|
|
469
|
+
})
|
|
470
|
+
|
|
399
471
|
test('toWindow exists on a range handle and on no other kind', () => {
|
|
400
472
|
const store = createSearchStore({
|
|
401
473
|
key: 'k-towindow-absent',
|
|
@@ -416,3 +488,91 @@ describe('types', () => {
|
|
|
416
488
|
expect(store.field.tab.kind).toBe('enum')
|
|
417
489
|
})
|
|
418
490
|
})
|
|
491
|
+
|
|
492
|
+
/** UTC `YYYY-MM-DD` — the shape `toWindow` deals in, with no timezone in the answer. */
|
|
493
|
+
function iso(d: Date): string {
|
|
494
|
+
return d.toISOString().slice(0, 10)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function addMonths(d: Date, months: number): Date {
|
|
498
|
+
const next = new Date(d)
|
|
499
|
+
next.setUTCMonth(next.getUTCMonth() + months)
|
|
500
|
+
return next
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
describe('a lazy fallback and the lanes', () => {
|
|
504
|
+
test('a thunk fallback on the URL lane throws at DEFINITION, naming the two ways out', () => {
|
|
505
|
+
expect(() =>
|
|
506
|
+
createSearchStore({
|
|
507
|
+
key: 'k-lazy-url',
|
|
508
|
+
fields: { since: field.string({ fallback: () => '2026-01-01' }) },
|
|
509
|
+
}),
|
|
510
|
+
).toThrow(/thunk fallback on the URL lane/)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
test('off the URL lane it resolves per read, in a search store too', () => {
|
|
514
|
+
let today = '2026-01-01'
|
|
515
|
+
const store = createSearchStore({
|
|
516
|
+
key: 'k-lazy-local',
|
|
517
|
+
fields: {
|
|
518
|
+
since: field.string({ fallback: () => today }, { url: false }),
|
|
519
|
+
scratch: field.string({ fallback: () => today }, { url: false, persist: false }),
|
|
520
|
+
},
|
|
521
|
+
})
|
|
522
|
+
expect(store.field.since.fallback).toBe('2026-01-01')
|
|
523
|
+
today = '2026-02-01'
|
|
524
|
+
expect(store.field.since.fallback).toBe('2026-02-01')
|
|
525
|
+
expect(store.field.scratch.fallback).toBe('2026-02-01')
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
describe('field.range — the `custom` flag is inferred, never widened', () => {
|
|
530
|
+
test('an omitted `custom` keeps `custom` out of the value type AND out of the options', () => {
|
|
531
|
+
const store = createSearchStore({
|
|
532
|
+
key: 'k-custom-inference',
|
|
533
|
+
fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d' }) },
|
|
534
|
+
})
|
|
535
|
+
type Search = ReturnType<typeof store.validateSearch>
|
|
536
|
+
|
|
537
|
+
const preset: Search['range'] = '7d'
|
|
538
|
+
// @ts-expect-error 'custom' is NOT a value of a range that never declared `custom: true` —
|
|
539
|
+
// this is the whole bug: written inline in `fields`, `C` used to widen to `boolean` against
|
|
540
|
+
// `AnyField`, and every value type gained a preset the field rejects at runtime.
|
|
541
|
+
const widened: Search['range'] = 'custom'
|
|
542
|
+
|
|
543
|
+
expect(preset).toBe('7d')
|
|
544
|
+
// `String(...)` because `widened`'s DECLARED type no longer admits the value it holds — the
|
|
545
|
+
// `@ts-expect-error` above is the assertion, this only keeps the binding used.
|
|
546
|
+
expect(String(widened)).toBe('custom')
|
|
547
|
+
expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', '30d'])
|
|
548
|
+
// The runtime agrees with the type: a `custom` preset does not decode.
|
|
549
|
+
expect(store.validateSearch({ range: 'custom', from: '2026-01-01', to: '2026-02-01' })).toEqual(
|
|
550
|
+
{ range: '30d', from: undefined, to: undefined },
|
|
551
|
+
)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
test('`custom: true` still admits it, in the type and in the options', () => {
|
|
555
|
+
const store = createSearchStore({
|
|
556
|
+
key: 'k-custom-true',
|
|
557
|
+
fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
|
|
558
|
+
})
|
|
559
|
+
type Search = ReturnType<typeof store.validateSearch>
|
|
560
|
+
const custom: Search['range'] = 'custom'
|
|
561
|
+
|
|
562
|
+
expect(custom).toBe('custom')
|
|
563
|
+
expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', '30d', 'custom'])
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('a `custom` flag held in a widened boolean still builds — it just stays widened', () => {
|
|
567
|
+
const allowCustom: boolean = true
|
|
568
|
+
const store = createSearchStore({
|
|
569
|
+
key: 'k-custom-widened',
|
|
570
|
+
fields: { range: field.range({ presets: ['7d'], fallback: '7d', custom: allowCustom }) },
|
|
571
|
+
})
|
|
572
|
+
type Search = ReturnType<typeof store.validateSearch>
|
|
573
|
+
const custom: Search['range'] = 'custom'
|
|
574
|
+
|
|
575
|
+
expect(custom).toBe('custom')
|
|
576
|
+
expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', 'custom'])
|
|
577
|
+
})
|
|
578
|
+
})
|