@proteos/ui 0.31.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/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { RecordFilterButton } from './record-filter-button'
|
|
5
|
+
import { RecordFilterChips } from './record-filter-chips'
|
|
6
|
+
import type { FilterField, FilterGroup } from './types'
|
|
7
|
+
|
|
8
|
+
/* =========================================================================
|
|
9
|
+
Composite story for the RecordFilter family. Mirrors the design's
|
|
10
|
+
Customer entity so the visual matches the prototype.
|
|
11
|
+
========================================================================= */
|
|
12
|
+
|
|
13
|
+
const customerFields: FilterField[] = [
|
|
14
|
+
{ path: 'name', shortLabel: 'Name', label: 'Name', type: 'string' },
|
|
15
|
+
{
|
|
16
|
+
path: 'email',
|
|
17
|
+
shortLabel: 'Email',
|
|
18
|
+
label: 'Email',
|
|
19
|
+
type: 'string',
|
|
20
|
+
inputType: 'email',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
path: 'tier',
|
|
24
|
+
shortLabel: 'Tier',
|
|
25
|
+
label: 'Tier',
|
|
26
|
+
type: 'enum',
|
|
27
|
+
options: [
|
|
28
|
+
{ value: 'free', label: 'Free' },
|
|
29
|
+
{ value: 'starter', label: 'Starter', variant: 'neutral' },
|
|
30
|
+
{ value: 'growth', label: 'Growth', variant: 'active' },
|
|
31
|
+
{ value: 'enterprise', label: 'Enterprise', variant: 'success' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: 'status',
|
|
36
|
+
shortLabel: 'Status',
|
|
37
|
+
label: 'Status',
|
|
38
|
+
type: 'enum',
|
|
39
|
+
options: [
|
|
40
|
+
{ value: 'active', label: 'Active', variant: 'success' },
|
|
41
|
+
{ value: 'trialing', label: 'Trialing', variant: 'active' },
|
|
42
|
+
{ value: 'churned', label: 'Churned', variant: 'danger' },
|
|
43
|
+
{ value: 'paused', label: 'Paused', variant: 'warning' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{ path: 'mrr', shortLabel: 'MRR', label: 'MRR', type: 'number' },
|
|
47
|
+
{ path: 'seats', shortLabel: 'Seats', label: 'Seats', type: 'integer' },
|
|
48
|
+
{ path: 'isVerified', shortLabel: 'Verified', label: 'Verified', type: 'boolean' },
|
|
49
|
+
{ path: 'signedUpAt', shortLabel: 'Signed up', label: 'Signed up', type: 'datetime' },
|
|
50
|
+
{ path: 'tags', shortLabel: 'Tags', label: 'Tags', type: 'array' },
|
|
51
|
+
{
|
|
52
|
+
path: 'owner.name',
|
|
53
|
+
shortLabel: 'Name',
|
|
54
|
+
parentLabel: 'Owner',
|
|
55
|
+
label: 'Owner / Name',
|
|
56
|
+
type: 'string',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: 'owner.email',
|
|
60
|
+
shortLabel: 'Email',
|
|
61
|
+
parentLabel: 'Owner',
|
|
62
|
+
label: 'Owner / Email',
|
|
63
|
+
type: 'string',
|
|
64
|
+
inputType: 'email',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
path: 'address.country',
|
|
68
|
+
shortLabel: 'Country',
|
|
69
|
+
parentLabel: 'Address',
|
|
70
|
+
label: 'Address / Country',
|
|
71
|
+
type: 'enum',
|
|
72
|
+
options: [
|
|
73
|
+
{ value: 'us', label: 'United States' },
|
|
74
|
+
{ value: 'ca', label: 'Canada' },
|
|
75
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
76
|
+
{ value: 'de', label: 'Germany' },
|
|
77
|
+
{ value: 'fr', label: 'France' },
|
|
78
|
+
{ value: 'jp', label: 'Japan' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const meta: Meta = {
|
|
84
|
+
title: 'Components/RecordFilter',
|
|
85
|
+
decorators: [
|
|
86
|
+
(Story) => (
|
|
87
|
+
<div className="p-12 min-h-[60vh]">
|
|
88
|
+
<Story />
|
|
89
|
+
</div>
|
|
90
|
+
),
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default meta
|
|
95
|
+
type Story = StoryObj
|
|
96
|
+
|
|
97
|
+
export const ToolbarComposition: Story = {
|
|
98
|
+
render: () => {
|
|
99
|
+
const [filter, setFilter] = useState<FilterGroup>({
|
|
100
|
+
logicalOperator: 'and',
|
|
101
|
+
elements: [
|
|
102
|
+
{ field: 'status', operator: 'in', value: ['active', 'trialing'] },
|
|
103
|
+
{ field: 'mrr', operator: 'gt', value: '1000' },
|
|
104
|
+
],
|
|
105
|
+
})
|
|
106
|
+
return (
|
|
107
|
+
<div className="flex flex-col gap-4 max-w-[1100px]">
|
|
108
|
+
<div className="flex items-end justify-between gap-4">
|
|
109
|
+
<div className="flex-1 min-w-0">
|
|
110
|
+
<RecordFilterChips fields={customerFields} filter={filter} onChange={setFilter} />
|
|
111
|
+
</div>
|
|
112
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
113
|
+
<RecordFilterButton
|
|
114
|
+
entityLabel="Customer"
|
|
115
|
+
fields={customerFields}
|
|
116
|
+
filter={filter}
|
|
117
|
+
onChange={setFilter}
|
|
118
|
+
/>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md border border-rule bg-surface font-mono text-[12px] text-ink-2 hover:border-ink-4"
|
|
122
|
+
>
|
|
123
|
+
+ New customer
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="rounded-lg border border-rule bg-surface p-4 text-[12px] font-mono text-ink-3">
|
|
128
|
+
(table renders here — wire up `filter` to your records query)
|
|
129
|
+
</div>
|
|
130
|
+
<pre className="rounded-md border border-rule bg-surface-2 p-3 text-[11px] font-mono text-ink-3 overflow-auto">
|
|
131
|
+
{JSON.stringify(filter, null, 2)}
|
|
132
|
+
</pre>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const Empty: Story = {
|
|
139
|
+
render: () => {
|
|
140
|
+
const [filter, setFilter] = useState<FilterGroup>({ logicalOperator: 'and', elements: [] })
|
|
141
|
+
return (
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<RecordFilterButton
|
|
144
|
+
entityLabel="Customer"
|
|
145
|
+
fields={customerFields}
|
|
146
|
+
filter={filter}
|
|
147
|
+
onChange={setFilter}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* tier ∈ {growth, enterprise} AND status = active AND (mrr > 1000 OR owner.team = ae)
|
|
155
|
+
— direct port of the example the design's chat transcript ends on. */
|
|
156
|
+
export const WithNestedGroups: Story = {
|
|
157
|
+
render: () => {
|
|
158
|
+
const [filter, setFilter] = useState<FilterGroup>({
|
|
159
|
+
logicalOperator: 'and',
|
|
160
|
+
elements: [
|
|
161
|
+
{ field: 'tier', operator: 'in', value: ['growth', 'enterprise'] },
|
|
162
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
163
|
+
],
|
|
164
|
+
groups: [
|
|
165
|
+
{
|
|
166
|
+
logicalOperator: 'or',
|
|
167
|
+
elements: [
|
|
168
|
+
{ field: 'mrr', operator: 'gt', value: '1000' },
|
|
169
|
+
{ field: 'owner.email', operator: 'contains', value: 'ae@' },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
})
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex flex-col gap-4 max-w-[1100px]">
|
|
176
|
+
<div className="flex items-end justify-between gap-4">
|
|
177
|
+
<div className="flex-1 min-w-0">
|
|
178
|
+
<RecordFilterChips fields={customerFields} filter={filter} onChange={setFilter} />
|
|
179
|
+
</div>
|
|
180
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
181
|
+
<RecordFilterButton
|
|
182
|
+
entityLabel="Customer"
|
|
183
|
+
fields={customerFields}
|
|
184
|
+
filter={filter}
|
|
185
|
+
onChange={setFilter}
|
|
186
|
+
isComplexEnabled
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<pre className="rounded-md border border-rule bg-surface-2 p-3 text-[11px] font-mono text-ink-3 overflow-auto">
|
|
191
|
+
{JSON.stringify(filter, null, 2)}
|
|
192
|
+
</pre>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
},
|
|
196
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { RecordFilterBuilderPanel } from './record-filter-builder-panel'
|
|
7
|
+
import { RecordFilterButton } from './record-filter-button'
|
|
8
|
+
import { RecordFilterChips } from './record-filter-chips'
|
|
9
|
+
import { __resetShowDetailsStores } from './show-details-store'
|
|
10
|
+
import type { FilterField, FilterGroup } from './types'
|
|
11
|
+
|
|
12
|
+
const fields: FilterField[] = [
|
|
13
|
+
{ path: 'name', shortLabel: 'Name', label: 'Name', type: 'string' },
|
|
14
|
+
{ path: 'mrr', shortLabel: 'MRR', label: 'MRR', type: 'number' },
|
|
15
|
+
{
|
|
16
|
+
path: 'status',
|
|
17
|
+
shortLabel: 'Status',
|
|
18
|
+
label: 'Status',
|
|
19
|
+
type: 'enum',
|
|
20
|
+
options: [
|
|
21
|
+
{ value: 'active', label: 'Active', variant: 'success' },
|
|
22
|
+
{ value: 'churned', label: 'Churned', variant: 'danger' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function Harness({ initial, storageKey }: { initial?: FilterGroup; storageKey?: string }) {
|
|
28
|
+
const [filter, setFilter] = useState<FilterGroup>(
|
|
29
|
+
initial ?? { logicalOperator: 'and', elements: [] },
|
|
30
|
+
)
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
<RecordFilterChips
|
|
34
|
+
fields={fields}
|
|
35
|
+
filter={filter}
|
|
36
|
+
onChange={setFilter}
|
|
37
|
+
{...(storageKey ? { storageKey } : {})}
|
|
38
|
+
/>
|
|
39
|
+
<RecordFilterButton
|
|
40
|
+
entityLabel="Customer"
|
|
41
|
+
fields={fields}
|
|
42
|
+
filter={filter}
|
|
43
|
+
onChange={setFilter}
|
|
44
|
+
{...(storageKey ? { storageKey } : {})}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
__resetShowDetailsStores()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
__resetShowDetailsStores()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('RecordFilter', () => {
|
|
59
|
+
it('renders the Filter button only when collapsed', () => {
|
|
60
|
+
// Default collapsed state: the Filter button owns UI; no chip row.
|
|
61
|
+
const { unmount } = render(<Harness storageKey="rf.test.empty" />)
|
|
62
|
+
expect(screen.getByRole('button', { name: /^Filter$/ })).toBeInTheDocument()
|
|
63
|
+
unmount()
|
|
64
|
+
|
|
65
|
+
// Pre-expand via localStorage → chips take over, Filter button hides.
|
|
66
|
+
window.localStorage.setItem('rf.test.empty.expanded', '1')
|
|
67
|
+
render(<Harness storageKey="rf.test.empty.expanded" />)
|
|
68
|
+
expect(screen.queryByRole('button', { name: /^Filter$/ })).not.toBeInTheDocument()
|
|
69
|
+
// The +Add inline pill is the entry point in expanded-empty state.
|
|
70
|
+
expect(screen.getByRole('button', { name: /Add filter/i })).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders chips for active filters and hides the Filter button when expanded', () => {
|
|
74
|
+
// Expanded is no longer the default — opt in explicitly.
|
|
75
|
+
window.localStorage.setItem('rf.test.expanded', '1')
|
|
76
|
+
render(
|
|
77
|
+
<Harness
|
|
78
|
+
storageKey="rf.test.expanded"
|
|
79
|
+
initial={{
|
|
80
|
+
logicalOperator: 'and',
|
|
81
|
+
elements: [
|
|
82
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
83
|
+
{ field: 'mrr', operator: 'gt', value: '500' },
|
|
84
|
+
],
|
|
85
|
+
}}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
// Two chips visible.
|
|
89
|
+
expect(screen.getByLabelText(/Edit filter 1: Status/)).toBeInTheDocument()
|
|
90
|
+
expect(screen.getByLabelText(/Edit filter 2: MRR/)).toBeInTheDocument()
|
|
91
|
+
// Filter button hidden because expanded with active filters.
|
|
92
|
+
expect(screen.queryByRole('button', { name: /^Filter$/i })).not.toBeInTheDocument()
|
|
93
|
+
// The collapse toggle is visible.
|
|
94
|
+
expect(screen.getByRole('button', { name: /Collapse/i })).toBeInTheDocument()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('toggles between collapsed and expanded', async () => {
|
|
98
|
+
const user = userEvent.setup()
|
|
99
|
+
// Start expanded so the Collapse toggle is present to click.
|
|
100
|
+
window.localStorage.setItem('rf.test.toggle', '1')
|
|
101
|
+
render(
|
|
102
|
+
<Harness
|
|
103
|
+
storageKey="rf.test.toggle"
|
|
104
|
+
initial={{
|
|
105
|
+
logicalOperator: 'and',
|
|
106
|
+
elements: [{ field: 'status', operator: 'eq', value: 'active' }],
|
|
107
|
+
}}
|
|
108
|
+
/>,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
await user.click(screen.getByRole('button', { name: /Collapse/i }))
|
|
112
|
+
|
|
113
|
+
// Now collapsed — Filter button visible (with count badge), chips hidden.
|
|
114
|
+
expect(screen.queryByLabelText(/Edit filter/)).not.toBeInTheDocument()
|
|
115
|
+
expect(screen.getByRole('button', { name: /Filter/i })).toBeInTheDocument()
|
|
116
|
+
// Count badge "1" somewhere inside the button.
|
|
117
|
+
expect(screen.getByText('1')).toBeInTheDocument()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('removes a filter via the chip × button', async () => {
|
|
121
|
+
const user = userEvent.setup()
|
|
122
|
+
window.localStorage.setItem('rf.test.remove', '1')
|
|
123
|
+
render(
|
|
124
|
+
<Harness
|
|
125
|
+
storageKey="rf.test.remove"
|
|
126
|
+
initial={{
|
|
127
|
+
logicalOperator: 'and',
|
|
128
|
+
elements: [
|
|
129
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
130
|
+
{ field: 'mrr', operator: 'gt', value: '500' },
|
|
131
|
+
],
|
|
132
|
+
}}
|
|
133
|
+
/>,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const removes = screen.getAllByRole('button', { name: 'Remove filter' })
|
|
137
|
+
expect(removes).toHaveLength(2)
|
|
138
|
+
if (!removes[0]) throw new Error('expected first remove button')
|
|
139
|
+
await user.click(removes[0])
|
|
140
|
+
|
|
141
|
+
expect(screen.queryByLabelText(/Edit filter 1: Status/)).not.toBeInTheDocument()
|
|
142
|
+
expect(screen.getByLabelText(/Edit filter 1: MRR/)).toBeInTheDocument()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('toggles AND <-> OR via the joiner pill between chips', async () => {
|
|
146
|
+
const user = userEvent.setup()
|
|
147
|
+
window.localStorage.setItem('rf.test.joiner', '1')
|
|
148
|
+
render(
|
|
149
|
+
<Harness
|
|
150
|
+
storageKey="rf.test.joiner"
|
|
151
|
+
initial={{
|
|
152
|
+
logicalOperator: 'and',
|
|
153
|
+
elements: [
|
|
154
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
155
|
+
{ field: 'mrr', operator: 'gt', value: '500' },
|
|
156
|
+
],
|
|
157
|
+
}}
|
|
158
|
+
/>,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const andPill = screen.getByRole('button', { name: 'AND' })
|
|
162
|
+
await user.click(andPill)
|
|
163
|
+
expect(screen.getByRole('button', { name: 'OR' })).toBeInTheDocument()
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/* =========================================================================
|
|
168
|
+
Keyboard-first creation — RecordFilterBuilderPanel.
|
|
169
|
+
========================================================================= */
|
|
170
|
+
|
|
171
|
+
function PanelHarness({
|
|
172
|
+
initial,
|
|
173
|
+
onClose = () => {},
|
|
174
|
+
}: {
|
|
175
|
+
initial?: FilterGroup
|
|
176
|
+
onClose?: () => void
|
|
177
|
+
}) {
|
|
178
|
+
const [filter, setFilter] = useState<FilterGroup>(
|
|
179
|
+
initial ?? { logicalOperator: 'and', elements: [] },
|
|
180
|
+
)
|
|
181
|
+
return (
|
|
182
|
+
<RecordFilterBuilderPanel
|
|
183
|
+
fields={fields}
|
|
184
|
+
filter={filter}
|
|
185
|
+
onChange={setFilter}
|
|
186
|
+
onClose={onClose}
|
|
187
|
+
/>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
describe('RecordFilterBuilderPanel — keyboard creation', () => {
|
|
192
|
+
it('auto-adds a pending row on mount when elements is empty', () => {
|
|
193
|
+
const onChange = vi.fn()
|
|
194
|
+
render(
|
|
195
|
+
<RecordFilterBuilderPanel
|
|
196
|
+
fields={fields}
|
|
197
|
+
filter={{ logicalOperator: 'and', elements: [] }}
|
|
198
|
+
onChange={onChange}
|
|
199
|
+
onClose={() => {}}
|
|
200
|
+
/>,
|
|
201
|
+
)
|
|
202
|
+
// First and only call appends a single pending element with field=''.
|
|
203
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
204
|
+
expect(onChange).toHaveBeenCalledWith({
|
|
205
|
+
logicalOperator: 'and',
|
|
206
|
+
elements: [{ field: '', operator: 'eq', value: '' }],
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('does not auto-add a pending row when elements is non-empty', () => {
|
|
211
|
+
const onChange = vi.fn()
|
|
212
|
+
render(
|
|
213
|
+
<RecordFilterBuilderPanel
|
|
214
|
+
fields={fields}
|
|
215
|
+
filter={{
|
|
216
|
+
logicalOperator: 'and',
|
|
217
|
+
elements: [{ field: 'name', operator: 'eq', value: 'x' }],
|
|
218
|
+
}}
|
|
219
|
+
onChange={onChange}
|
|
220
|
+
onClose={() => {}}
|
|
221
|
+
/>,
|
|
222
|
+
)
|
|
223
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('Enter inside a plain value Input adds a new pending row', async () => {
|
|
227
|
+
const user = userEvent.setup()
|
|
228
|
+
render(
|
|
229
|
+
<PanelHarness
|
|
230
|
+
initial={{
|
|
231
|
+
logicalOperator: 'and',
|
|
232
|
+
elements: [{ field: 'name', operator: 'eq', value: 'tonio' }],
|
|
233
|
+
}}
|
|
234
|
+
/>,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
// The string row's value cell is a plain <input>. Find it and focus it.
|
|
238
|
+
const valueInput = screen.getByLabelText('Name')
|
|
239
|
+
await user.click(valueInput)
|
|
240
|
+
await user.keyboard('{Enter}')
|
|
241
|
+
|
|
242
|
+
// After commit + add new pending row, two rows should exist: the
|
|
243
|
+
// original plus a fresh pending one. Probe for the second row's
|
|
244
|
+
// AttributePicker placeholder ("Find attribute…") — there's at least
|
|
245
|
+
// one in the DOM either way (the original row also has one), but with
|
|
246
|
+
// a pending row added there should be one *empty* attribute combobox.
|
|
247
|
+
const placeholders = screen.getAllByPlaceholderText('Find attribute…')
|
|
248
|
+
// Original row has one (showing the picked field as input value); the
|
|
249
|
+
// pending row adds another. Total = 2.
|
|
250
|
+
expect(placeholders).toHaveLength(2)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('Cmd+Enter on the panel calls onClose', async () => {
|
|
254
|
+
const user = userEvent.setup()
|
|
255
|
+
const onClose = vi.fn()
|
|
256
|
+
render(
|
|
257
|
+
<PanelHarness
|
|
258
|
+
onClose={onClose}
|
|
259
|
+
initial={{
|
|
260
|
+
logicalOperator: 'and',
|
|
261
|
+
elements: [{ field: 'name', operator: 'eq', value: 'tonio' }],
|
|
262
|
+
}}
|
|
263
|
+
/>,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
// Focus any control inside the panel and fire Cmd+Enter.
|
|
267
|
+
const valueInput = screen.getByLabelText('Name')
|
|
268
|
+
await user.click(valueInput)
|
|
269
|
+
await user.keyboard('{Meta>}{Enter}{/Meta}')
|
|
270
|
+
|
|
271
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
/* =========================================================================
|
|
276
|
+
Nested groups — opt-in via `isComplexEnabled`.
|
|
277
|
+
========================================================================= */
|
|
278
|
+
|
|
279
|
+
function NestedHarness({
|
|
280
|
+
initial,
|
|
281
|
+
isComplexEnabled,
|
|
282
|
+
}: {
|
|
283
|
+
initial?: FilterGroup
|
|
284
|
+
isComplexEnabled: boolean
|
|
285
|
+
}) {
|
|
286
|
+
const [filter, setFilter] = useState<FilterGroup>(
|
|
287
|
+
initial ?? { logicalOperator: 'and', elements: [] },
|
|
288
|
+
)
|
|
289
|
+
return (
|
|
290
|
+
<RecordFilterBuilderPanel
|
|
291
|
+
fields={fields}
|
|
292
|
+
filter={filter}
|
|
293
|
+
onChange={setFilter}
|
|
294
|
+
onClose={() => {}}
|
|
295
|
+
isComplexEnabled={isComplexEnabled}
|
|
296
|
+
/>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
describe('RecordFilterBuilderPanel — nested groups', () => {
|
|
301
|
+
it('hides the Add group button when isComplexEnabled is false', () => {
|
|
302
|
+
render(
|
|
303
|
+
<RecordFilterBuilderPanel
|
|
304
|
+
fields={fields}
|
|
305
|
+
filter={{
|
|
306
|
+
logicalOperator: 'and',
|
|
307
|
+
elements: [{ field: 'name', operator: 'eq', value: 'x' }],
|
|
308
|
+
}}
|
|
309
|
+
onChange={() => {}}
|
|
310
|
+
onClose={() => {}}
|
|
311
|
+
/>,
|
|
312
|
+
)
|
|
313
|
+
expect(screen.queryByRole('button', { name: /Add group/i })).not.toBeInTheDocument()
|
|
314
|
+
expect(screen.getByRole('button', { name: /Add filter/i })).toBeInTheDocument()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('shows Add group, "Wrap in group" hover icon, and the GROUP eyebrow when enabled', async () => {
|
|
318
|
+
const user = userEvent.setup()
|
|
319
|
+
render(
|
|
320
|
+
<NestedHarness
|
|
321
|
+
isComplexEnabled
|
|
322
|
+
initial={{
|
|
323
|
+
logicalOperator: 'and',
|
|
324
|
+
elements: [{ field: 'name', operator: 'eq', value: 'x' }],
|
|
325
|
+
}}
|
|
326
|
+
/>,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
expect(screen.getByRole('button', { name: /Wrap in group/i })).toBeInTheDocument()
|
|
330
|
+
const addGroup = screen.getByRole('button', { name: /Add group/i })
|
|
331
|
+
await user.click(addGroup)
|
|
332
|
+
|
|
333
|
+
// After clicking Add group: a Group eyebrow appears + the new sub-group's
|
|
334
|
+
// "Empty group" prompt appears in the body.
|
|
335
|
+
expect(screen.getByText(/^Group$/)).toBeInTheDocument()
|
|
336
|
+
expect(screen.getByText(/Empty group/i)).toBeInTheDocument()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('Wrap in group moves the leaf into a new sub-group with the opposite joiner', async () => {
|
|
340
|
+
const user = userEvent.setup()
|
|
341
|
+
const onFilter = vi.fn<(f: FilterGroup) => void>()
|
|
342
|
+
render(
|
|
343
|
+
<ProbePanel
|
|
344
|
+
onFilter={onFilter}
|
|
345
|
+
initial={{
|
|
346
|
+
logicalOperator: 'and',
|
|
347
|
+
elements: [{ field: 'name', operator: 'eq', value: 'x' }],
|
|
348
|
+
}}
|
|
349
|
+
/>,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
await user.click(screen.getByRole('button', { name: /Wrap in group/i }))
|
|
353
|
+
|
|
354
|
+
expect(onFilter).toHaveBeenCalled()
|
|
355
|
+
const next = onFilter.mock.calls.at(-1)?.[0]
|
|
356
|
+
expect(next?.elements).toHaveLength(0)
|
|
357
|
+
expect(next?.groups).toHaveLength(1)
|
|
358
|
+
expect(next?.groups?.[0]?.logicalOperator).toBe('or')
|
|
359
|
+
expect(next?.groups?.[0]?.elements?.[0]?.field).toBe('name')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('Removing a nested group via the group × leaves elements untouched', async () => {
|
|
363
|
+
const user = userEvent.setup()
|
|
364
|
+
const onFilter = vi.fn<(f: FilterGroup) => void>()
|
|
365
|
+
render(
|
|
366
|
+
<ProbePanel
|
|
367
|
+
onFilter={onFilter}
|
|
368
|
+
initial={{
|
|
369
|
+
logicalOperator: 'and',
|
|
370
|
+
elements: [{ field: 'name', operator: 'eq', value: 'x' }],
|
|
371
|
+
groups: [
|
|
372
|
+
{
|
|
373
|
+
logicalOperator: 'or',
|
|
374
|
+
elements: [{ field: 'mrr', operator: 'gt', value: '1000' }],
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
}}
|
|
378
|
+
/>,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
await user.click(screen.getByRole('button', { name: /Remove group/i }))
|
|
382
|
+
|
|
383
|
+
const next = onFilter.mock.calls.at(-1)?.[0]
|
|
384
|
+
expect(next?.groups ?? []).toHaveLength(0)
|
|
385
|
+
expect(next?.elements).toHaveLength(1)
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
function ProbePanel({
|
|
390
|
+
initial,
|
|
391
|
+
onFilter,
|
|
392
|
+
}: {
|
|
393
|
+
initial: FilterGroup
|
|
394
|
+
onFilter: (f: FilterGroup) => void
|
|
395
|
+
}) {
|
|
396
|
+
const [filter, setFilter] = useState<FilterGroup>(initial)
|
|
397
|
+
return (
|
|
398
|
+
<RecordFilterBuilderPanel
|
|
399
|
+
fields={fields}
|
|
400
|
+
filter={filter}
|
|
401
|
+
onChange={(next) => {
|
|
402
|
+
setFilter(next)
|
|
403
|
+
onFilter(next)
|
|
404
|
+
}}
|
|
405
|
+
onClose={() => {}}
|
|
406
|
+
isComplexEnabled
|
|
407
|
+
/>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
describe('RecordFilterChips — nested groups', () => {
|
|
412
|
+
it('renders a read-only group pill for each sub-group', () => {
|
|
413
|
+
// Chips only render when expanded; the store now defaults to collapsed.
|
|
414
|
+
window.localStorage.setItem('rf.showDetails', '1')
|
|
415
|
+
render(
|
|
416
|
+
<RecordFilterChips
|
|
417
|
+
fields={fields}
|
|
418
|
+
filter={{
|
|
419
|
+
logicalOperator: 'and',
|
|
420
|
+
elements: [{ field: 'name', operator: 'eq', value: 'x' }],
|
|
421
|
+
groups: [
|
|
422
|
+
{
|
|
423
|
+
logicalOperator: 'or',
|
|
424
|
+
elements: [
|
|
425
|
+
{ field: 'mrr', operator: 'gt', value: '1000' },
|
|
426
|
+
{ field: 'status', operator: 'eq', value: 'active' },
|
|
427
|
+
],
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
}}
|
|
431
|
+
onChange={() => {}}
|
|
432
|
+
/>,
|
|
433
|
+
)
|
|
434
|
+
expect(screen.getByText(/or group/i)).toBeInTheDocument()
|
|
435
|
+
expect(screen.getByText(/· 2/)).toBeInTheDocument()
|
|
436
|
+
})
|
|
437
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
showDetails persistence — a tiny pub/sub on top of localStorage so the
|
|
5
|
+
FilterButton (collapsed) and the FilterChips (expanded) stay in sync
|
|
6
|
+
regardless of where they're mounted in the page. Keyed by a configurable
|
|
7
|
+
storage key so multiple filter UIs can coexist.
|
|
8
|
+
========================================================================= */
|
|
9
|
+
|
|
10
|
+
type Listener = (next: boolean) => void
|
|
11
|
+
|
|
12
|
+
const stores = new Map<string, Set<Listener>>()
|
|
13
|
+
|
|
14
|
+
function listeners(key: string): Set<Listener> {
|
|
15
|
+
let set = stores.get(key)
|
|
16
|
+
if (!set) {
|
|
17
|
+
set = new Set()
|
|
18
|
+
stores.set(key, set)
|
|
19
|
+
}
|
|
20
|
+
return set
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function read(key: string): boolean {
|
|
24
|
+
if (typeof window === 'undefined') return false
|
|
25
|
+
try {
|
|
26
|
+
// Default collapsed: only an explicit '1' expands. Unset / '0' → collapsed.
|
|
27
|
+
return window.localStorage.getItem(key) === '1'
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function write(key: string, next: boolean) {
|
|
34
|
+
if (typeof window !== 'undefined') {
|
|
35
|
+
try {
|
|
36
|
+
window.localStorage.setItem(key, next ? '1' : '0')
|
|
37
|
+
} catch {
|
|
38
|
+
/* ignore quota / privacy-mode errors */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const fn of listeners(key)) fn(next)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useShowDetails(storageKey = 'rf.showDetails') {
|
|
45
|
+
const [value, setValue] = useState<boolean>(() => read(storageKey))
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const fn: Listener = (next) => setValue(next)
|
|
49
|
+
listeners(storageKey).add(fn)
|
|
50
|
+
setValue(read(storageKey))
|
|
51
|
+
return () => {
|
|
52
|
+
listeners(storageKey).delete(fn)
|
|
53
|
+
}
|
|
54
|
+
}, [storageKey])
|
|
55
|
+
|
|
56
|
+
const toggle = () => write(storageKey, !read(storageKey))
|
|
57
|
+
const set = (next: boolean) => write(storageKey, next)
|
|
58
|
+
|
|
59
|
+
return [value, toggle, set] as const
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Test-only escape hatch — clears all listeners and storage values. */
|
|
63
|
+
export function __resetShowDetailsStores() {
|
|
64
|
+
stores.clear()
|
|
65
|
+
if (typeof window !== 'undefined') {
|
|
66
|
+
try {
|
|
67
|
+
window.localStorage.clear()
|
|
68
|
+
} catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|