@akinon/akifilter 1.8.0 → 1.9.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/README.md +306 -0
- package/dist/cjs/akifilter.d.ts +19 -0
- package/dist/cjs/akifilter.d.ts.map +1 -1
- package/dist/cjs/akifilter.js +74 -17
- package/dist/cjs/common/storage.d.ts.map +1 -1
- package/dist/cjs/common/storage.js +47 -2
- package/dist/cjs/components/file-import-button.d.ts +33 -0
- package/dist/cjs/components/file-import-button.d.ts.map +1 -0
- package/dist/cjs/components/file-import-button.js +37 -0
- package/dist/cjs/components/filter-toolbar.d.ts.map +1 -1
- package/dist/cjs/components/filter-toolbar.js +4 -18
- package/dist/cjs/components/section-fieldset.d.ts +16 -2
- package/dist/cjs/components/section-fieldset.d.ts.map +1 -1
- package/dist/cjs/components/section-fieldset.js +104 -32
- package/dist/cjs/components/visibility-modal.js +1 -1
- package/dist/cjs/constants.d.ts +6 -0
- package/dist/cjs/constants.d.ts.map +1 -1
- package/dist/cjs/constants.js +7 -1
- package/dist/cjs/hooks/use-field-visibility.d.ts.map +1 -1
- package/dist/cjs/hooks/use-field-visibility.js +10 -1
- package/dist/cjs/i18n/translations/en.d.ts +8 -0
- package/dist/cjs/i18n/translations/en.d.ts.map +1 -1
- package/dist/cjs/i18n/translations/en.js +8 -0
- package/dist/cjs/i18n/translations/tr.d.ts +8 -0
- package/dist/cjs/i18n/translations/tr.d.ts.map +1 -1
- package/dist/cjs/i18n/translations/tr.js +8 -0
- package/dist/cjs/styles.css +36 -11
- package/dist/cjs/types.d.ts +146 -3
- package/dist/cjs/types.d.ts.map +1 -1
- package/dist/cjs/utils/schema.d.ts +10 -0
- package/dist/cjs/utils/schema.d.ts.map +1 -1
- package/dist/cjs/utils/schema.js +13 -1
- package/dist/esm/akifilter.d.ts +19 -0
- package/dist/esm/akifilter.d.ts.map +1 -1
- package/dist/esm/akifilter.js +75 -18
- package/dist/esm/common/storage.d.ts.map +1 -1
- package/dist/esm/common/storage.js +47 -2
- package/dist/esm/components/file-import-button.d.ts +33 -0
- package/dist/esm/components/file-import-button.d.ts.map +1 -0
- package/dist/esm/components/file-import-button.js +33 -0
- package/dist/esm/components/filter-toolbar.d.ts.map +1 -1
- package/dist/esm/components/filter-toolbar.js +4 -18
- package/dist/esm/components/section-fieldset.d.ts +16 -2
- package/dist/esm/components/section-fieldset.d.ts.map +1 -1
- package/dist/esm/components/section-fieldset.js +104 -32
- package/dist/esm/components/visibility-modal.js +2 -2
- package/dist/esm/constants.d.ts +6 -0
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.js +6 -0
- package/dist/esm/hooks/use-field-visibility.d.ts.map +1 -1
- package/dist/esm/hooks/use-field-visibility.js +10 -1
- package/dist/esm/i18n/translations/en.d.ts +8 -0
- package/dist/esm/i18n/translations/en.d.ts.map +1 -1
- package/dist/esm/i18n/translations/en.js +8 -0
- package/dist/esm/i18n/translations/tr.d.ts +8 -0
- package/dist/esm/i18n/translations/tr.d.ts.map +1 -1
- package/dist/esm/i18n/translations/tr.js +8 -0
- package/dist/esm/styles.css +36 -11
- package/dist/esm/types.d.ts +146 -3
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/utils/schema.d.ts +10 -0
- package/dist/esm/utils/schema.d.ts.map +1 -1
- package/dist/esm/utils/schema.js +10 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -42,3 +42,309 @@
|
|
|
42
42
|
8. **Testing & Quality Targets**
|
|
43
43
|
- Cover all interactive flows (form entry, chip removal, modal toggles, persistence, conditional schema changes, CSV/XLS hooks) with Vitest and Testing Library, targeting ≥90% coverage without regressing existing suites.
|
|
44
44
|
- Document sample integration snippets so partner apps can verify behavior during development.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Section Fields
|
|
49
|
+
|
|
50
|
+
Fields declared with `type: 'section'` render as collapsible panels below the
|
|
51
|
+
main filter grid. Every field inside a section is part of the same form state as
|
|
52
|
+
the top-level filters, so section values are emitted through `onValuesChange`
|
|
53
|
+
and persisted to local storage exactly like regular filters.
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
const filterSchema = [
|
|
57
|
+
{ key: 'sku', type: 'text', label: 'SKU', config: { visible: true } },
|
|
58
|
+
{
|
|
59
|
+
key: 'excludeSection',
|
|
60
|
+
type: 'section',
|
|
61
|
+
label: 'Exclude Filters',
|
|
62
|
+
isExcludeSection: true,
|
|
63
|
+
fields: [{ key: 'excludeSku', type: 'text', label: 'Exclude SKU' }]
|
|
64
|
+
}
|
|
65
|
+
];
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Section flags
|
|
69
|
+
|
|
70
|
+
| Flag | Effect |
|
|
71
|
+
| ------------------- | ---------------------------------------------------------------------------------------- |
|
|
72
|
+
| `defaultExpanded` | `false` starts the panel collapsed. |
|
|
73
|
+
| `isExcludeSection` | Chips for the section's fields render with the exclude styling (red chip + exclude icon). |
|
|
74
|
+
| `selectableFields` | Adds a funnel button that opens a per-section visibility modal. |
|
|
75
|
+
| `enableImportCsv` | Adds a CSV import button to the section header. Requires `onSectionImportCsv`. |
|
|
76
|
+
| `enableImportXls` | Adds an XLS/XLSX import button to the section header. Requires `onSectionImportXls`. |
|
|
77
|
+
|
|
78
|
+
> Only Akifilter reads these flags. `isExcludeSection` and `selectableFields`
|
|
79
|
+
> are declared on `SectionField` in `@akinon/akiform-builder` for historical
|
|
80
|
+
> reasons and have builder setters of their own; `enableImportCsv` /
|
|
81
|
+
> `enableImportXls` are Akifilter's own props and reach a builder-made section
|
|
82
|
+
> through `field().extend({ enableImportCsv: true })`, which is where any new
|
|
83
|
+
> Akifilter-only prop belongs. See the note on `SectionField` in that package's
|
|
84
|
+
> `types.ts`.
|
|
85
|
+
|
|
86
|
+
### Persistence
|
|
87
|
+
|
|
88
|
+
Section values are stored under the same `…:values` local-storage entry as the
|
|
89
|
+
top-level filters and rehydrated on the next mount, so a page refresh keeps
|
|
90
|
+
exclude filters filled in. Per-section visibility selections (`selectableFields`)
|
|
91
|
+
are stored separately under `…:section:<sectionKey>`.
|
|
92
|
+
|
|
93
|
+
The storage key is hashed from the **top-level** field signature only, so editing
|
|
94
|
+
a section's fields does not invalidate the entry (and existing entries survive an
|
|
95
|
+
upgrade). Stale values from an older schema are instead rejected on read: a
|
|
96
|
+
persisted value whose shape cannot fit its field's current `type`/`mode` is
|
|
97
|
+
dropped rather than restored — e.g. an array left behind by a
|
|
98
|
+
`select`/`multiple` field is not fed into a field that is now `text`. `file`
|
|
99
|
+
fields are never restored at all, since a `File` cannot survive JSON
|
|
100
|
+
serialisation.
|
|
101
|
+
|
|
102
|
+
### Section-level CSV / XLS import
|
|
103
|
+
|
|
104
|
+
A section can expose its own file-import buttons in the collapse header — the
|
|
105
|
+
same affordance the top-level toolbar offers through `enableImportCsv` /
|
|
106
|
+
`onImportCsv`, and rendered conditionally in the same way as the
|
|
107
|
+
`selectableFields` funnel. This is typically used to exclude a large set of
|
|
108
|
+
records by uploading a file instead of typing values one by one.
|
|
109
|
+
|
|
110
|
+
A button renders only when **both** halves are present: the schema flag on the
|
|
111
|
+
section and the matching handler on the component. The handler receives the
|
|
112
|
+
section's `key` first, so a single handler can serve every section.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
<Akifilter
|
|
116
|
+
filterSchema={[
|
|
117
|
+
{
|
|
118
|
+
key: 'excludeSection',
|
|
119
|
+
type: 'section',
|
|
120
|
+
label: 'Exclude Filters',
|
|
121
|
+
isExcludeSection: true,
|
|
122
|
+
enableImportCsv: true,
|
|
123
|
+
enableImportXls: true,
|
|
124
|
+
fields: [{ key: 'excludeSku', type: 'text', label: 'Exclude SKU' }]
|
|
125
|
+
}
|
|
126
|
+
]}
|
|
127
|
+
onSectionImportCsv={(sectionKey, file) => uploadExcludeFile(sectionKey, file)}
|
|
128
|
+
onSectionImportXls={(sectionKey, file) => uploadExcludeFile(sectionKey, file)}
|
|
129
|
+
/>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Akifilter never reads or parses the file — it only surfaces the picker and hands
|
|
133
|
+
the `File` to the host application. To show the upload as an active filter, feed
|
|
134
|
+
it back through `externalAppliedFilters` (set `isExclude: true` on the chip to
|
|
135
|
+
match the exclude styling) and clear it from `onRemoveExternalFilter`.
|
|
136
|
+
|
|
137
|
+
Import buttons combine freely with `selectableFields`: the funnel is rendered
|
|
138
|
+
after the file buttons inside the same header action group. Both mouse clicks and
|
|
139
|
+
Enter presses are stopped from bubbling out of that group, so activating a header
|
|
140
|
+
action never toggles the panel underneath it.
|
|
141
|
+
|
|
142
|
+
With the builder API:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
field()
|
|
146
|
+
.key('excludeSection')
|
|
147
|
+
.type('section')
|
|
148
|
+
.label('Exclude Filters')
|
|
149
|
+
.isExcludeSection(true)
|
|
150
|
+
// Akifilter's own flags travel through `extend()`; `isExcludeSection` and
|
|
151
|
+
// `selectableFields` predate it and keep their setters.
|
|
152
|
+
.extend({ enableImportCsv: true, enableImportXls: true })
|
|
153
|
+
.fields([field().key('excludeSku').type('text').label('Exclude SKU').build()])
|
|
154
|
+
.build();
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Localization
|
|
158
|
+
|
|
159
|
+
Section header action copy lives under the `section.actions` namespace
|
|
160
|
+
(`importCsv`, `importXls`, `importCsvTooltip`, `importXlsTooltip`). The tooltip
|
|
161
|
+
keys interpolate `{{section}}` with the section's label.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Field Labels
|
|
166
|
+
|
|
167
|
+
A field's `label` drives its form input. Two optional props override it for one
|
|
168
|
+
surface each, so the chip and the picker can say something different from the
|
|
169
|
+
input:
|
|
170
|
+
|
|
171
|
+
| Prop | Overrides | Falls back to |
|
|
172
|
+
| ----------------- | ---------------------------------------------- | ------------- |
|
|
173
|
+
| `appliedLabel` | The applied-filter chip (and its remove button's accessible name). | `label` → `placeholder` → `key` |
|
|
174
|
+
| `visibilityLabel` | The field's row in the "Select visible filters" modal. | `label` → `placeholder` → `key` |
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
const filterSchema: AkifilterSchema = [
|
|
178
|
+
{
|
|
179
|
+
key: 'orderNo',
|
|
180
|
+
type: 'text',
|
|
181
|
+
label: 'Order No', // form input
|
|
182
|
+
appliedLabel: 'Order number', // chip: "Order number: 1001"
|
|
183
|
+
visibilityLabel: 'Order reference no' // modal row
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
key: 'excludeSection',
|
|
187
|
+
type: 'section',
|
|
188
|
+
label: 'Exclude Filters',
|
|
189
|
+
isExcludeSection: true,
|
|
190
|
+
fields: [
|
|
191
|
+
{
|
|
192
|
+
key: 'excludeStatus',
|
|
193
|
+
type: 'text',
|
|
194
|
+
label: 'Status',
|
|
195
|
+
// The chip is rendered outside the section, so it carries the context
|
|
196
|
+
// the section header would otherwise provide.
|
|
197
|
+
appliedLabel: 'Excluded status'
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
];
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Notes:
|
|
205
|
+
|
|
206
|
+
- Both props are optional. A field that sets neither behaves exactly as before,
|
|
207
|
+
so adding them to one field never affects the rest of the schema.
|
|
208
|
+
- Section children support them too, including inside exclude sections.
|
|
209
|
+
- The visibility modal's search box matches `visibilityLabel`, `label`,
|
|
210
|
+
`placeholder` and `key`, so an overridden field stays findable under either
|
|
211
|
+
name.
|
|
212
|
+
- Neither prop affects the form input's own label or its accessible name — use
|
|
213
|
+
`label` for that.
|
|
214
|
+
- Storage is keyed by field `key`, never by any label, so renaming or
|
|
215
|
+
overriding labels does not invalidate persisted values or visibility
|
|
216
|
+
selections.
|
|
217
|
+
- They are Akifilter's own props, not part of the shared form schema. With the
|
|
218
|
+
fluent builder they travel through its generic `extend()` hatch:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
import { field } from '@akinon/akiform-builder';
|
|
222
|
+
|
|
223
|
+
field()
|
|
224
|
+
.key('orderNo')
|
|
225
|
+
.type('text')
|
|
226
|
+
.label('Order No')
|
|
227
|
+
.extend({ appliedLabel: 'Order number' })
|
|
228
|
+
.build();
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The call is type-checked even though the builder knows nothing about filters:
|
|
232
|
+
Akifilter declares its props on the builder's `FieldExtensions` interface, and
|
|
233
|
+
`extend()` accepts *only* declared keys — a typo or a wrong type is a compile
|
|
234
|
+
error, and an application that wants to hang its own metadata on a field
|
|
235
|
+
declares it the same way:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
declare module '@akinon/akiform-builder' {
|
|
239
|
+
interface FieldExtensions {
|
|
240
|
+
requestKey?: string;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`AkiformBuilder` ignores all of it — it renders the form label from `label`
|
|
246
|
+
alone.
|
|
247
|
+
|
|
248
|
+
See the `With Distinct Labels` Storybook story for a live example.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Multi-Value Filters and Switchable Filter Types
|
|
253
|
+
|
|
254
|
+
### Tags input out of the box
|
|
255
|
+
|
|
256
|
+
A `select` field with `mode: 'tags'` and no options is a free-form multi-value
|
|
257
|
+
input: type a value, press Enter to turn it into a removable tag, repeat. The
|
|
258
|
+
applied chip joins the entries with `, `.
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
{
|
|
262
|
+
key: 'keywords',
|
|
263
|
+
type: 'select',
|
|
264
|
+
label: 'Keywords',
|
|
265
|
+
mode: 'tags',
|
|
266
|
+
options: []
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`mode` is part of the storage signature, so switching a field between single
|
|
271
|
+
and multi-value modes invalidates its persisted entry instead of restoring an
|
|
272
|
+
incompatible shape.
|
|
273
|
+
|
|
274
|
+
### One field, several chips (`appliedChips`)
|
|
275
|
+
|
|
276
|
+
By default a field contributes one applied-filter chip. A field that owns
|
|
277
|
+
several filters at once — a bucket per filter type, a range that reads better as
|
|
278
|
+
two chips — can produce its own instead:
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
{
|
|
282
|
+
key: 'attributeName',
|
|
283
|
+
type: 'custom',
|
|
284
|
+
label: 'Attribute Name',
|
|
285
|
+
// Value shape is the field's own business, e.g.
|
|
286
|
+
// { mode: 'exact', buckets: { contains: ['a', 'b'], exact: ['c'] } }
|
|
287
|
+
appliedChips: ({ field, value, formValues }) => [
|
|
288
|
+
{ id: 'contains', label: 'Attribute Name (Contains)', value: 'a, b' },
|
|
289
|
+
{ id: 'exact', label: 'Attribute Name (Exact match)', value: 'c' }
|
|
290
|
+
],
|
|
291
|
+
// Return the value the field should hold after the chip is removed,
|
|
292
|
+
// or `null` to clear the field.
|
|
293
|
+
onRemoveAppliedChip: ({ field, value, id }) => dropBucket(value, id),
|
|
294
|
+
render: ({ control }) => <MyInput control={control} />
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
| Prop | Behaviour |
|
|
299
|
+
| ---- | --------- |
|
|
300
|
+
| `appliedChips` | Replaces the field's single chip with one chip per returned entry. Return `[]` for "no chips right now". Each entry is `{ id, value, label?, isExclude? }`; `label` defaults to the field's applied label, `isExclude` to whether the field sits in an exclude section. |
|
|
301
|
+
| `onRemoveAppliedChip` | Maps one chip's × back onto a new field value, which Akifilter commits, persists and emits. Without it the chips render but cannot be removed. |
|
|
302
|
+
|
|
303
|
+
`id` must be stable for the same logical entry and unique within the field. Chip
|
|
304
|
+
keys are `<field key>::<chip id>` internally and are split at the first
|
|
305
|
+
separator, so an id may contain `::` — a field key may not.
|
|
306
|
+
|
|
307
|
+
`appliedChips` is only called while the field's own value is non-empty — the
|
|
308
|
+
emptiness rules that suppress a plain chip (`null`/`undefined`, a blank string,
|
|
309
|
+
an empty array) suppress these too. Use `formValues` to *format* the chips (a
|
|
310
|
+
unit or a mode held by a sibling field), not to derive them from a sibling while
|
|
311
|
+
this field holds nothing.
|
|
312
|
+
|
|
313
|
+
With the fluent builder both props travel the same route as the label
|
|
314
|
+
overrides: `field().….extend({ appliedChips, onRemoveAppliedChip })`.
|
|
315
|
+
|
|
316
|
+
Because every chip is derived from the field's **form value**, persistence comes
|
|
317
|
+
for free: Akifilter stores and rehydrates the composite value like any other
|
|
318
|
+
one, and `Clear all` clears the field like any other. Nothing has to be mirrored
|
|
319
|
+
into host state or written to storage by the application.
|
|
320
|
+
|
|
321
|
+
Declaring `appliedChips` also tells the storage layer that the field owns its
|
|
322
|
+
value shape, so a composite (object) value is rehydrated instead of being
|
|
323
|
+
rejected as schema drift — the same exemption `type: 'custom'` gets. Without the
|
|
324
|
+
prop, only `custom` fields may hold object values across a reload.
|
|
325
|
+
|
|
326
|
+
### A filter whose type the user can switch
|
|
327
|
+
|
|
328
|
+
Some filters carry a *type* next to their value — contains / exact match /
|
|
329
|
+
starts with — where each type emits under its own request key and several types
|
|
330
|
+
can be active at the same time. The `With Tag Type Selector` Storybook story
|
|
331
|
+
implements the whole flow on top of the pieces above:
|
|
332
|
+
|
|
333
|
+
| Concern | Mechanism |
|
|
334
|
+
| ------- | --------- |
|
|
335
|
+
| The input (tags + the type switcher on its right) | A `custom` field. Bind it with `useController({ control: renderProps.control, name })`. |
|
|
336
|
+
| Where the state lives | One form value holding a bucket per type plus the active type — so Akifilter persists and rehydrates all of it, active type included. |
|
|
337
|
+
| One chip per type, each with its own label | `appliedChips`. |
|
|
338
|
+
| Removing a single type | `onRemoveAppliedChip`, returning the value without that bucket (`null` when the last one goes). |
|
|
339
|
+
| Mapping buckets onto request keys | Host logic, since one logical filter can emit several keys at once. |
|
|
340
|
+
|
|
341
|
+
The flow the story demonstrates:
|
|
342
|
+
|
|
343
|
+
1. With `Contains` active, entering `filter a` and `filter b` produces
|
|
344
|
+
`Attribute Name (Contains): filter a, filter b`.
|
|
345
|
+
2. Switching the type leaves that bucket with its own chip and shows the new
|
|
346
|
+
type's (empty) bucket in the input.
|
|
347
|
+
3. Entering `filter c` adds `Attribute Name (Exact match): filter c`, and the
|
|
348
|
+
payload carries both request keys.
|
|
349
|
+
4. Switching back shows the earlier bucket in the input again.
|
|
350
|
+
5. A page refresh restores every bucket **and** the active type.
|
package/dist/cjs/akifilter.d.ts
CHANGED
|
@@ -71,6 +71,25 @@ export type AkifilterProps<TFieldValues extends AkifilterFieldValues = Akifilter
|
|
|
71
71
|
* Notified whenever the set of active filter buttons changes.
|
|
72
72
|
*/
|
|
73
73
|
onFilterButtonsChange?: (activeButtons: AkifilterButton[]) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Triggered when a user picks a CSV file from a section header's import
|
|
76
|
+
* button. Receives the section's `key` and the selected file, so one handler
|
|
77
|
+
* can serve every section.
|
|
78
|
+
*
|
|
79
|
+
* The button only renders for sections that set `enableImportCsv: true` —
|
|
80
|
+
* mirroring how the top-level toolbar pairs `enableImportCsv` with
|
|
81
|
+
* `onImportCsv`. Akifilter never parses the file; the host application
|
|
82
|
+
* decides what to do with it (e.g. call an exclude-by-file endpoint and
|
|
83
|
+
* surface a chip through `externalAppliedFilters`).
|
|
84
|
+
*/
|
|
85
|
+
onSectionImportCsv?: (sectionKey: string, file: File) => void;
|
|
86
|
+
/**
|
|
87
|
+
* Triggered when a user picks an XLS/XLSX file from a section header's import
|
|
88
|
+
* button. Receives the section's `key` and the selected file.
|
|
89
|
+
*
|
|
90
|
+
* The button only renders for sections that set `enableImportXls: true`.
|
|
91
|
+
*/
|
|
92
|
+
onSectionImportXls?: (sectionKey: string, file: File) => void;
|
|
74
93
|
};
|
|
75
94
|
export declare const Akifilter: {
|
|
76
95
|
<TFieldValues extends AkifilterFieldValues = FieldValues>(props: AkifilterProps<TFieldValues>): React.JSX.Element;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"akifilter.d.ts","sourceRoot":"","sources":["../../src/akifilter.tsx"],"names":[],"mappings":"AAAA,OAAO,cAAc,CAAC;AAGtB,OAAO,EAIL,WAAW,EACX,IAAI,EAIL,MAAM,iBAAiB,CAAC;AAYzB,OAAO,KAAK,MAAM,OAAO,CAAC;AAc1B,OAAO,EACL,KAAK,aAAa,EAEnB,MAAM,8BAA8B,CAAC;AAgBtC,OAAO,KAAK,EACV,mBAAmB,EACnB,eAAe,EAEf,eAAe,EAChB,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"akifilter.d.ts","sourceRoot":"","sources":["../../src/akifilter.tsx"],"names":[],"mappings":"AAAA,OAAO,cAAc,CAAC;AAGtB,OAAO,EAIL,WAAW,EACX,IAAI,EAIL,MAAM,iBAAiB,CAAC;AAYzB,OAAO,KAAK,MAAM,OAAO,CAAC;AAc1B,OAAO,EACL,KAAK,aAAa,EAEnB,MAAM,8BAA8B,CAAC;AAgBtC,OAAO,KAAK,EACV,mBAAmB,EACnB,eAAe,EAEf,eAAe,EAChB,MAAM,SAAS,CAAC;AAYjB,KAAK,oBAAoB,GAAG,WAAW,CAAC;AAiLxC,MAAM,MAAM,cAAc,CACxB,YAAY,SAAS,oBAAoB,GAAG,oBAAoB,IAC9D;IACF;;OAEG;IACH,YAAY,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,CAAC;IAC7C;;OAEG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;OAEG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IACtC;;OAEG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;IACzD;;OAEG;IACH,qBAAqB,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,KAAK,IAAI,CAAC;IAClE;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IACnC;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IACnC;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB;;OAEG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;OAEG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;OAEG;IACH,gBAAgB,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC;IAClD;;;OAGG;IACH,sBAAsB,CAAC,EAAE,aAAa,EAAE,CAAC;IACzC;;OAEG;IACH,sBAAsB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/C;;;;;OAKG;IACH,aAAa,CAAC,EAAE,eAAe,EAAE,CAAC;IAClC;;OAEG;IACH,qBAAqB,CAAC,EAAE,CAAC,aAAa,EAAE,eAAe,EAAE,KAAK,IAAI,CAAC;IACnE;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IAC9D;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;CAC/D,CAAC;AAk9BF,eAAO,MAAM,SAAS;KACpB,YAAY,SAAS,oBAAoB,uBAElC,cAAc,CAAC,YAAY,CAAC;;CAoBpC,CAAC"}
|
package/dist/cjs/akifilter.js
CHANGED
|
@@ -48,6 +48,12 @@ const values_1 = require("./utils/values");
|
|
|
48
48
|
* routed back to the owning button.
|
|
49
49
|
*/
|
|
50
50
|
const BUTTON_CHIP_PREFIX = 'akifilter-button:';
|
|
51
|
+
// Chips a field produced through `appliedChips`. The key carries both halves —
|
|
52
|
+
// `<field key>::<chip id>` — so a removal can be routed back to the field that
|
|
53
|
+
// owns it. Split at the *first* separator, so a chip id may contain one; a
|
|
54
|
+
// field key may not, which holds for form paths.
|
|
55
|
+
const FIELD_CHIP_PREFIX = 'akifilter-field:';
|
|
56
|
+
const FIELD_CHIP_SEPARATOR = '::';
|
|
51
57
|
const FilterFormItem = (_a) => {
|
|
52
58
|
var _b, _c;
|
|
53
59
|
var { children, control, name, valuePropName } = _a, props = __rest(_a, ["children", "control", "name", "valuePropName"]);
|
|
@@ -128,7 +134,7 @@ const resolveClearedFieldValue = (field, defaultValue) => {
|
|
|
128
134
|
return undefined;
|
|
129
135
|
}
|
|
130
136
|
};
|
|
131
|
-
const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onValuesChange, onVisibleFieldsChange, onImportCsv, onImportXls, onClearAll, enableImportCsv, enableImportXls, filterActionsRef, externalAppliedFilters, onRemoveExternalFilter, filterButtons, onFilterButtonsChange }) => {
|
|
137
|
+
const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onValuesChange, onVisibleFieldsChange, onImportCsv, onImportXls, onClearAll, enableImportCsv, enableImportXls, filterActionsRef, externalAppliedFilters, onRemoveExternalFilter, filterButtons, onFilterButtonsChange, onSectionImportCsv, onSectionImportXls }) => {
|
|
132
138
|
// Separate regular fields from section fields
|
|
133
139
|
const { regularFields, sectionFields } = react_1.default.useMemo(() => (0, schema_1.partitionSchema)(filterSchema), [filterSchema]);
|
|
134
140
|
const excludeSectionKeys = react_1.default.useMemo(() => (0, schema_1.deriveExcludeSectionKeys)(sectionFields), [sectionFields]);
|
|
@@ -138,16 +144,19 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
138
144
|
const schemaDefaults = react_1.default.useMemo(() => (0, schema_1.normaliseValuesBySchema)(flattenedSchema, (0, schema_1.extractDefaultValues)(flattenedSchema)), [flattenedSchema]);
|
|
139
145
|
const externalDefaults = react_1.default.useMemo(() => (0, schema_1.normaliseValuesBySchema)(flattenedSchema, defaultValues), [flattenedSchema, defaultValues]);
|
|
140
146
|
const baseDefaultValues = react_1.default.useMemo(() => (Object.assign(Object.assign({}, schemaDefaults), externalDefaults)), [schemaDefaults, externalDefaults]);
|
|
141
|
-
// Use state for persistedDefaults to make it reactive to localStorage changes
|
|
147
|
+
// Use state for persistedDefaults to make it reactive to localStorage changes.
|
|
148
|
+
// Read against the *flattened* schema: `readStoredValues` drops keys that are
|
|
149
|
+
// not in the schema it is given, so passing only the top-level fields would
|
|
150
|
+
// silently discard every section field's persisted value on reload.
|
|
142
151
|
const [persistedDefaults, setPersistedDefaults] = react_1.default.useState(() => {
|
|
143
152
|
var _a;
|
|
144
|
-
return (0, schema_1.normaliseValuesBySchema)(flattenedSchema, (_a = (0, storage_1.readStoredValues)(
|
|
153
|
+
return (0, schema_1.normaliseValuesBySchema)(flattenedSchema, (_a = (0, storage_1.readStoredValues)(flattenedSchema, storageKey)) !== null && _a !== void 0 ? _a : undefined);
|
|
145
154
|
});
|
|
146
155
|
// Re-read from localStorage when storageKey changes
|
|
147
156
|
react_1.default.useEffect(() => {
|
|
148
157
|
var _a;
|
|
149
|
-
setPersistedDefaults((0, schema_1.normaliseValuesBySchema)(flattenedSchema, (_a = (0, storage_1.readStoredValues)(
|
|
150
|
-
}, [flattenedSchema,
|
|
158
|
+
setPersistedDefaults((0, schema_1.normaliseValuesBySchema)(flattenedSchema, (_a = (0, storage_1.readStoredValues)(flattenedSchema, storageKey)) !== null && _a !== void 0 ? _a : undefined));
|
|
159
|
+
}, [flattenedSchema, storageKey]);
|
|
151
160
|
const mergedDefaultValues = react_1.default.useMemo(() => (Object.assign(Object.assign({}, baseDefaultValues), persistedDefaults)), [baseDefaultValues, persistedDefaults]);
|
|
152
161
|
const formMethods = (0, akiform_1.useForm)({
|
|
153
162
|
defaultValues: mergedDefaultValues
|
|
@@ -200,7 +209,23 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
200
209
|
if (field.type === constants_1.FIELD_TYPES.CHECKBOX && currentValue !== true) {
|
|
201
210
|
return acc;
|
|
202
211
|
}
|
|
203
|
-
const label =
|
|
212
|
+
const label = (0, schema_1.getAppliedLabel)(field);
|
|
213
|
+
// A field that owns several filters renders its own chips: only it knows
|
|
214
|
+
// how to split its value and how to format each part.
|
|
215
|
+
if (typeof field.appliedChips === 'function') {
|
|
216
|
+
field
|
|
217
|
+
.appliedChips({ field, value: currentValue, formValues })
|
|
218
|
+
.forEach(chip => {
|
|
219
|
+
var _a, _b;
|
|
220
|
+
acc.push({
|
|
221
|
+
key: `${FIELD_CHIP_PREFIX}${key}${FIELD_CHIP_SEPARATOR}${chip.id}`,
|
|
222
|
+
label: (_a = chip.label) !== null && _a !== void 0 ? _a : label,
|
|
223
|
+
value: chip.value,
|
|
224
|
+
isExclude: (_b = chip.isExclude) !== null && _b !== void 0 ? _b : excludeSectionKeys.has(key)
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
return acc;
|
|
228
|
+
}
|
|
204
229
|
const fieldOptions = 'options' in field && field.options ? field.options : undefined;
|
|
205
230
|
const resolveLabel = (val) => {
|
|
206
231
|
var _a;
|
|
@@ -418,17 +443,14 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
418
443
|
emitValues,
|
|
419
444
|
persistValues
|
|
420
445
|
]);
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
const defaultValue = schemaDefaults[String(schemaField.key)];
|
|
427
|
-
const nextValue = resolveClearedFieldValue(schemaField, defaultValue);
|
|
446
|
+
// Writes one field's value and pushes the result through the whole
|
|
447
|
+
// persist/emit path immediately, bypassing the debounce the way a removal
|
|
448
|
+
// has to. Shared by chip removals — whole-field and per-chip alike.
|
|
449
|
+
const commitFieldValue = react_1.default.useCallback((fieldKey, nextValue) => {
|
|
428
450
|
// Manually construct the updated form values instead of relying on setValue + getValues
|
|
429
451
|
// because setValue is batched and getValues() wouldn't reflect the change immediately
|
|
430
452
|
const currentFormValues = formMethods.getValues();
|
|
431
|
-
const updatedFormValues = Object.assign(Object.assign({}, currentFormValues), { [
|
|
453
|
+
const updatedFormValues = Object.assign(Object.assign({}, currentFormValues), { [fieldKey]: nextValue });
|
|
432
454
|
// Normalize the values (removes empty/undefined entries)
|
|
433
455
|
const nextValues = (0, values_1.normaliseOutputValues)(flattenedSchema, updatedFormValues);
|
|
434
456
|
// updatedFormValues holds an explicit cleared value (e.g. `null` for custom/file
|
|
@@ -450,7 +472,36 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
450
472
|
hasInitialValuesRef.current = Object.keys(nextValues).length > 0;
|
|
451
473
|
// Emit the change to parent
|
|
452
474
|
emitValues(nextValues);
|
|
453
|
-
}, [
|
|
475
|
+
}, [flattenedSchema, formMethods, emitValues, persistValues]);
|
|
476
|
+
const handleRemoveFilter = react_1.default.useCallback((key) => {
|
|
477
|
+
const schemaField = flattenedSchema.find(field => String(field.key) === key);
|
|
478
|
+
if (!schemaField) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const defaultValue = schemaDefaults[String(schemaField.key)];
|
|
482
|
+
commitFieldValue(String(schemaField.key), resolveClearedFieldValue(schemaField, defaultValue));
|
|
483
|
+
}, [schemaDefaults, flattenedSchema, commitFieldValue]);
|
|
484
|
+
// Removal of a single chip produced by a field's own `appliedChips`. The
|
|
485
|
+
// field decides what its value becomes; Akifilter just commits it.
|
|
486
|
+
const handleFieldChipRemove = react_1.default.useCallback((chipKey) => {
|
|
487
|
+
const separatorIndex = chipKey.indexOf(FIELD_CHIP_SEPARATOR);
|
|
488
|
+
if (separatorIndex === -1) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const fieldKey = chipKey.slice(0, separatorIndex);
|
|
492
|
+
const chipId = chipKey.slice(separatorIndex + FIELD_CHIP_SEPARATOR.length);
|
|
493
|
+
const schemaField = flattenedSchema.find(field => String(field.key) === fieldKey);
|
|
494
|
+
if (!schemaField ||
|
|
495
|
+
typeof schemaField.onRemoveAppliedChip !== 'function') {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const currentValue = formMethods.getValues()[fieldKey];
|
|
499
|
+
commitFieldValue(fieldKey, schemaField.onRemoveAppliedChip({
|
|
500
|
+
field: schemaField,
|
|
501
|
+
value: currentValue,
|
|
502
|
+
id: chipId
|
|
503
|
+
}));
|
|
504
|
+
}, [flattenedSchema, formMethods, commitFieldValue]);
|
|
454
505
|
// Toggle a filter button. Button state lives outside the form, so this syncs
|
|
455
506
|
// the value ref, persists, and emits immediately (bypassing the form debounce).
|
|
456
507
|
const handleButtonToggle = react_1.default.useCallback((button) => {
|
|
@@ -514,6 +565,9 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
514
565
|
if (key.startsWith(BUTTON_CHIP_PREFIX)) {
|
|
515
566
|
handleButtonRemove(key.slice(BUTTON_CHIP_PREFIX.length));
|
|
516
567
|
}
|
|
568
|
+
else if (key.startsWith(FIELD_CHIP_PREFIX)) {
|
|
569
|
+
handleFieldChipRemove(key.slice(FIELD_CHIP_PREFIX.length));
|
|
570
|
+
}
|
|
517
571
|
else if (externalAppliedFilters === null || externalAppliedFilters === void 0 ? void 0 : externalAppliedFilters.some(f => f.key === key)) {
|
|
518
572
|
onRemoveExternalFilter === null || onRemoveExternalFilter === void 0 ? void 0 : onRemoveExternalFilter(key);
|
|
519
573
|
}
|
|
@@ -524,7 +578,8 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
524
578
|
externalAppliedFilters,
|
|
525
579
|
onRemoveExternalFilter,
|
|
526
580
|
handleRemoveFilter,
|
|
527
|
-
handleButtonRemove
|
|
581
|
+
handleButtonRemove,
|
|
582
|
+
handleFieldChipRemove
|
|
528
583
|
]);
|
|
529
584
|
const handleClearAllApplied = react_1.default.useCallback(() => {
|
|
530
585
|
// Reset filter buttons before clearing the form so the merged emission in
|
|
@@ -599,7 +654,9 @@ const AkifilterContent = ({ filterSchema, storageNamespace, defaultValues, onVal
|
|
|
599
654
|
react_1.default.createElement("div", { className: "akinon-filter__form-grid", "data-testid": "akifilter-form-grid" },
|
|
600
655
|
visibleFields.map(renderFormField),
|
|
601
656
|
visibleFields.length === 0 && regularFields.length > 0 ? (react_1.default.createElement("div", { className: "akinon-filter__empty" }, i18n_1.i18n.t('form.noVisibleFields'))) : null),
|
|
602
|
-
sectionFields.length > 0 && (react_1.default.createElement("div", { className: "akinon-filter__section-fields" }, sectionFields.map(section => (react_1.default.createElement(section_fieldset_1.SectionFieldset, { key: String(section.key), section: section, storageKey: storageKey, renderFormField: renderFormField, onRemoveField: handleRemoveFilter, formValues: normalisedValues
|
|
657
|
+
sectionFields.length > 0 && (react_1.default.createElement("div", { className: "akinon-filter__section-fields" }, sectionFields.map(section => (react_1.default.createElement(section_fieldset_1.SectionFieldset, { key: String(section.key), section: section, storageKey: storageKey, renderFormField: renderFormField, onRemoveField: handleRemoveFilter, formValues: normalisedValues, onImportCsv: onSectionImportCsv &&
|
|
658
|
+
(file => onSectionImportCsv(String(section.key), file)), onImportXls: onSectionImportXls &&
|
|
659
|
+
(file => onSectionImportXls(String(section.key), file)) }))))))),
|
|
603
660
|
react_1.default.createElement(visibility_modal_1.VisibilityModal, Object.assign({}, modalProps)))));
|
|
604
661
|
};
|
|
605
662
|
const AkifilterEmptyState = () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../../src/common/storage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../../src/common/storage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAInD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AA4B/C,eAAO,MAAM,eAAe,GAAI,YAAY,SAAS,WAAW,EAC9D,QAAQ,cAAc,CAAC,YAAY,CAAC,EAAE,EACtC,YAAY,MAAM,KACjB,MAKF,CAAC;AAIF,eAAO,MAAM,eAAe,GAAI,YAAY,SAAS,WAAW,EAC9D,QAAQ,cAAc,CAAC,YAAY,CAAC,EAAE,EACtC,YAAY,MAAM,KACjB,MAAM,EAAE,GAAG,IAoBb,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,YAAY,MAAM,EAAE,MAAM,MAAM,EAAE,KAAG,IAQrE,CAAC;AAEF,eAAO,MAAM,yBAAyB,GACpC,YAAY,MAAM,EAClB,YAAY,MAAM,GAAG,MAAM,KAC1B,MAAuD,CAAC;AAE3D,eAAO,MAAM,gBAAgB,GAAI,YAAY,MAAM,KAAG,IAQrD,CAAC;AA0DF,eAAO,MAAM,gBAAgB,GAAI,YAAY,SAAS,WAAW,EAC/D,QAAQ,cAAc,CAAC,YAAY,CAAC,EAAE,EACtC,YAAY,MAAM,KACjB,OAAO,CAAC,YAAY,CAAC,GAAG,IAqC1B,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,YAAY,SAAS,WAAW,EAChE,YAAY,MAAM,EAClB,QAAQ,OAAO,CAAC,YAAY,CAAC,KAC5B,IAeF,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,YAAY,MAAM,KAAG,IAQtD,CAAC;AAKF,eAAO,MAAM,sBAAsB,GACjC,YAAY,MAAM,KACjB,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,GAAG,IAwB3B,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAClC,YAAY,MAAM,EAClB,QAAQ,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,KAC7B,IAcF,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAI,YAAY,MAAM,KAAG,IAQ5D,CAAC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.clearStoredButtonValues = exports.writeStoredButtonValues = exports.readStoredButtonValues = exports.clearStoredValues = exports.writeStoredValues = exports.readStoredValues = exports.clearVisibleKeys = exports.buildSectionVisibilityKey = exports.writeVisibleKeys = exports.readVisibleKeys = exports.buildStorageKey = void 0;
|
|
4
|
+
const constants_1 = require("../constants");
|
|
4
5
|
const i18n_1 = require("../i18n");
|
|
5
6
|
const STORAGE_PREFIX = 'akifilter:v1';
|
|
6
7
|
const VALUES_SUFFIX = ':values';
|
|
@@ -76,6 +77,46 @@ const clearVisibleKeys = (storageKey) => {
|
|
|
76
77
|
};
|
|
77
78
|
exports.clearVisibleKeys = clearVisibleKeys;
|
|
78
79
|
const buildValuesStorageKey = (storageKey) => `${storageKey}${VALUES_SUFFIX}`;
|
|
80
|
+
/**
|
|
81
|
+
* Guards a persisted value against schema drift.
|
|
82
|
+
*
|
|
83
|
+
* The storage key is hashed from the top-level field signature only, so a
|
|
84
|
+
* section field whose `type`/`mode` changed between releases keeps the same key
|
|
85
|
+
* and its stale value would otherwise be restored into an incompatible widget
|
|
86
|
+
* (e.g. a multi-select's array landing in a text input, which then renders
|
|
87
|
+
* `a,b` and is re-emitted as an array to a host expecting a string).
|
|
88
|
+
*
|
|
89
|
+
* Deliberately conservative: it only rejects shapes that no widget of that type
|
|
90
|
+
* can hold, and never polices string-vs-number.
|
|
91
|
+
*/
|
|
92
|
+
const isStoredValueCompatible = (field, value) => {
|
|
93
|
+
// `File` objects stringify to `{}`, so a persisted file value is always junk.
|
|
94
|
+
if (field.type === constants_1.FIELD_TYPES.FILE) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
// Custom fields own their value shape; the package cannot judge it. So does
|
|
98
|
+
// any field that renders its own chips: `appliedChips` means the value is a
|
|
99
|
+
// composite only that field interprets, whatever the field's type is.
|
|
100
|
+
if (field.type === constants_1.FIELD_TYPES.CUSTOM ||
|
|
101
|
+
typeof field.appliedChips === 'function') {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const isMultiSelect = field.type === constants_1.FIELD_TYPES.SELECT &&
|
|
105
|
+
(field.mode === constants_1.FIELD_MODES.MULTIPLE || field.mode === constants_1.FIELD_MODES.TAGS);
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
// Only multi-selects hold arrays. A bare scalar is still fine for them —
|
|
108
|
+
// `normaliseValuesBySchema` wraps it.
|
|
109
|
+
return isMultiSelect;
|
|
110
|
+
}
|
|
111
|
+
// Nothing else round-trips through a filter widget as a plain object.
|
|
112
|
+
if (value !== null && typeof value === 'object') {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (field.type === constants_1.FIELD_TYPES.CHECKBOX) {
|
|
116
|
+
return typeof value === 'boolean';
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
79
120
|
const readStoredValues = (schema, storageKey) => {
|
|
80
121
|
if (!isBrowser)
|
|
81
122
|
return null;
|
|
@@ -88,9 +129,13 @@ const readStoredValues = (schema, storageKey) => {
|
|
|
88
129
|
if (!parsed || typeof parsed !== 'object') {
|
|
89
130
|
return null;
|
|
90
131
|
}
|
|
91
|
-
const
|
|
132
|
+
const fieldsByKey = new Map(schema.map(field => [String(field.key), field]));
|
|
92
133
|
return Object.entries(parsed).reduce((acc, [key, value]) => {
|
|
93
|
-
|
|
134
|
+
const field = fieldsByKey.get(String(key));
|
|
135
|
+
// Unknown keys belong to a different schema version; values whose shape no
|
|
136
|
+
// longer fits their field are stale leftovers of one (see
|
|
137
|
+
// `isStoredValueCompatible`).
|
|
138
|
+
if (field && isStoredValueCompatible(field, value)) {
|
|
94
139
|
acc[key] =
|
|
95
140
|
value;
|
|
96
141
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export type FileImportButtonProps = {
|
|
3
|
+
/**
|
|
4
|
+
* Accepted file extensions, forwarded to the hidden file input
|
|
5
|
+
* (e.g. `'.csv'`, `'.xls,.xlsx'`).
|
|
6
|
+
*/
|
|
7
|
+
accept: string;
|
|
8
|
+
/**
|
|
9
|
+
* Text rendered inside the button (e.g. `'CSV'`).
|
|
10
|
+
*/
|
|
11
|
+
label: string;
|
|
12
|
+
/**
|
|
13
|
+
* Tooltip / accessible name. Falls back to `label` when omitted, which is
|
|
14
|
+
* what the top-level toolbar relies on.
|
|
15
|
+
*/
|
|
16
|
+
title?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Called with the picked file. The component never uploads anything itself;
|
|
19
|
+
* the host application decides what to do with the file.
|
|
20
|
+
*/
|
|
21
|
+
onImport: (file: File) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Extra class names appended to the button.
|
|
24
|
+
*/
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Button that opens the native file picker and hands the selected file to
|
|
29
|
+
* `onImport`. Shared by the top-level filter toolbar and the section headers so
|
|
30
|
+
* both surfaces behave identically.
|
|
31
|
+
*/
|
|
32
|
+
export declare const FileImportButton: ({ accept, label, title, onImport, className }: FileImportButtonProps) => React.JSX.Element;
|
|
33
|
+
//# sourceMappingURL=file-import-button.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-import-button.d.ts","sourceRoot":"","sources":["../../../src/components/file-import-button.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,MAAM,MAAM,qBAAqB,GAAG;IAClC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IAC/B;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,+CAM9B,qBAAqB,sBA6CvB,CAAC"}
|