@akinon/akifilter
v1.12.2
Published
Akifilter is a filtering library for Akinon frontend applications.
Readme
akifilter
Legacy Functionality Snapshot (@akinon/ui/akifilter-old)
- Schema-driven forms: Builds the main filter UI from
filterSchemausingFormBuilderWatch, applyinglabelRightAlignedprops and reflecting initial values fromuseAppliedFilters[0]or persisted storage. - Context-managed state:
FilterContextProviderderivesmainPathfrom the router (orcustomMainPath), storesappliedFilters,applicableFilters, andshownFiltersviauseImmerReducer, and exposes actions for updates. - Local storage persistence: Reads/writes
appliedFilters-{mainPath}andshownFilters-{mainPath}so filter selections survive reloads; defaults to the first eight filters when no history exists. - Parent synchronization: Propagates every applied-filter change back through
useAppliedFilters[1], allowing host apps to react or override initial state. - Applied filter chips: Renders active filters with labels sourced from placeholders, formats values (dates via akidate utilities, arrays, booleans) for display, and supports removing individual filters or clearing all.
- Dynamic filter visibility:
SelectShownFiltersopens a modal with searchable, paginated checkboxes for every applicable filter; selections updateshownFiltersand trigger form recreation to avoid stale state. - Conditional filters: Optional
conditionalFiltersSchemarenders a secondary form whose values merge intoappliedFilterswithout local storage persistence, rebuilding whenever the schema changes. - Utility helpers: Provides path parsing (
getPathFragments) to scope state per route and a tree search helper (getTreeNodeByValue) for nested option lookups. - Internationalization scaffolding: Loads static i18n bundles (EN/TR) through
Akilocalefor component copy such as the panel title.
Implementation Plan (User Experience Focus)
Filter Workspace Shell
- Recreate the legacy layout with updated styling hooks, keeping the header title, filter-selection trigger, applied filter strip, and main filter grid so existing users feel familiar while allowing future visual tweaks.
- Integrate
akilocalefor default copy (title, modal labels) and expose translation keys for host apps.
Schema Intake & Form Rendering
- Accept an external
filterSchema(and optionalconditionalFiltersSchema) and translate each field intoakiformwidgets, honoring component-specific props, labels, and placeholders. - Default-render only “primary” filters on first load (configurable count), but respect any persisted selections on subsequent visits.
- Accept an external
User Input & Applied State
- Mirror user edits into an internal state model while emitting JSON payloads to the host via the provided
useAppliedFilterstuple so parent apps receive canonical data. - Display currently applied filters as removable chips; value formatting should transparently handle primitives, arrays, booleans, and date-like objects.
- Mirror user edits into an internal state model while emitting JSON payloads to the host via the provided
Filter Visibility Management
- Provide a modal (using
@akinon/ui-modal,@akinon/ui-input,@akinon/ui-pagination) that lists all available filters with search, pagination, and checkbox toggles to control visibility. - Persist visibility selections and main-form order to local storage, ensuring a stable experience across sessions.
- Provide a modal (using
Persistence Strategy
- Derive unique local-storage keys using a caller-supplied prefix plus a deterministic suffix (e.g., hash of schema or route) so multiple filter instances cannot collide.
- Support optional overrides (custom key + custom main path) while always appending our uniqueness salt.
Conditional Filter Surface
- Render an auxiliary
akiformsection beneath the main filters whenconditionalFiltersSchemaexists, reset its form state whenever the schema changes, and merge its values into the emitted applied-filter JSON without persisting to storage.
- Render an auxiliary
Bulk Actions & Import Filters
- Retain the “clear all” action for quick resets, updating UI state, storage, and parent callbacks consistently.
- Plan dedicated affordances for CSV/XLS import: display buttons in the options toolbar, accept uploads via
@akinon/ui-upload, and parse/normalize records into applied filters (details to be implemented in later stages).
Testing & Quality Targets
- 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.
- Document sample integration snippets so partner apps can verify behavior during development.
Value Persistence (persistValues)
By default, field values and active filter buttons are written to local storage
and restored on the next mount. Set persistValues={false} when the filter has
to start from values the host supplies (for example filters loaded from an API):
<Akifilter
filterSchema={filterSchema}
storageNamespace="saved-filters"
persistValues={false}
defaultValues={savedFilters}
onValuesChange={setFilters}
/>- Values and button state are never read or written; the form starts from
schema defaults +
defaultValues, and buttons start inactive. - Values stored before the prop was set are ignored, not deleted.
- Visible-field (and per-section visibility) persistence is unaffected.
- Leaving the prop out keeps today's behaviour (
true). persistValuesanddefaultValuesare read on mount; changing them later does not reset the form. When the values come from an API, renderAkifilteronly after they have loaded. TurningpersistValuesoff after mount stops further writes but keeps what was already restored.
Footer
footer renders host content inside the filter card, below every field and
section. Pass a node, or a function that receives { values } (the
onValuesChange shape, not debounced) to render conditionally. A falsy result
(null, undefined, false, '', 0) renders nothing:
<Akifilter
filterSchema={filterSchema}
footer={({ values }) =>
Object.keys(values).length > 0 ? <Button>Save as rule</Button> : null
}
/>The footer sits outside the form, so a button there never submits it.
Custom Title
title replaces the default translated "Filters" heading. Pass a string or a
node; pass null, false or '' to hide the heading and keep only the
toolbar:
<Akifilter filterSchema={filterSchema} title="Order Filters" />Section Fields
Fields declared with type: 'section' render as collapsible panels below the
main filter grid. Every field inside a section is part of the same form state as
the top-level filters, so section values are emitted through onValuesChange
and persisted to local storage exactly like regular filters.
const filterSchema = [
{ key: 'sku', type: 'text', label: 'SKU', config: { visible: true } },
{
key: 'excludeSection',
type: 'section',
label: 'Exclude Filters',
isExcludeSection: true,
fields: [{ key: 'excludeSku', type: 'text', label: 'Exclude SKU' }]
}
];Section flags
| Flag | Effect |
| ------------------- | ---------------------------------------------------------------------------------------- |
| defaultExpanded | false starts the panel collapsed. |
| isExcludeSection | Chips for the section's fields render with the exclude styling (red chip + exclude icon). |
| selectableFields | Adds a funnel button that opens a per-section visibility modal. |
| enableImportCsv | Adds a CSV import button to the section header. Requires onSectionImportCsv. |
| enableImportXls | Adds an XLS/XLSX import button to the section header. Requires onSectionImportXls. |
Only Akifilter reads these flags.
isExcludeSectionandselectableFieldsare declared onSectionFieldin@akinon/akiform-builderfor historical reasons and have builder setters of their own;enableImportCsv/enableImportXlsare Akifilter's own props and reach a builder-made section throughfield().extend({ enableImportCsv: true }), which is where any new Akifilter-only prop belongs. See the note onSectionFieldin that package'stypes.ts.
Persistence
Section values are stored under the same …:values local-storage entry as the
top-level filters and rehydrated on the next mount, so a page refresh keeps
exclude filters filled in. Per-section visibility selections (selectableFields)
are stored separately under …:section:<sectionKey>.
The storage key is hashed from the top-level field signature only, so editing
a section's fields does not invalidate the entry (and existing entries survive an
upgrade). Stale values from an older schema are instead rejected on read: a
persisted value whose shape cannot fit its field's current type/mode is
dropped rather than restored — e.g. an array left behind by a
select/multiple field is not fed into a field that is now text. file
fields are never restored at all, since a File cannot survive JSON
serialisation.
Section field visibility
onVisibleFieldsChange reports the top-level fields only. To follow what a
section renders, pass onSectionVisibleFieldsChange — it receives the section's
key and the keys of its visible fields, on mount and whenever the list changes.
const handleSectionVisibleFieldsChange = useCallback(
(sectionKey: string, keys: string[]) =>
setSectionVisibleKeys(current => ({ ...current, [sectionKey]: keys })),
[]
);
<Akifilter
filterSchema={filterSchema}
onSectionVisibleFieldsChange={handleSectionVisibleFieldsChange}
/>;For a selectableFields section the keys follow the section's visibility modal;
any other section renders all of its fields and reports every key. Keep the
handler's identity stable, as with onVisibleFieldsChange: a new function on
every render re-notifies with an unchanged list.
Section-level CSV / XLS import
A section can expose its own file-import buttons in the collapse header — the
same affordance the top-level toolbar offers through enableImportCsv /
onImportCsv, and rendered conditionally in the same way as the
selectableFields funnel. This is typically used to exclude a large set of
records by uploading a file instead of typing values one by one.
A button renders only when both halves are present: the schema flag on the
section and the matching handler on the component. The handler receives the
section's key first, so a single handler can serve every section.
<Akifilter
filterSchema={[
{
key: 'excludeSection',
type: 'section',
label: 'Exclude Filters',
isExcludeSection: true,
enableImportCsv: true,
enableImportXls: true,
fields: [{ key: 'excludeSku', type: 'text', label: 'Exclude SKU' }]
}
]}
onSectionImportCsv={(sectionKey, file) => uploadExcludeFile(sectionKey, file)}
onSectionImportXls={(sectionKey, file) => uploadExcludeFile(sectionKey, file)}
/>Akifilter never reads or parses the file — it only surfaces the picker and hands
the File to the host application. To show the upload as an active filter, feed
it back through externalAppliedFilters (set isExclude: true on the chip to
match the exclude styling) and clear it from onRemoveExternalFilter.
Import buttons combine freely with selectableFields: the funnel is rendered
after the file buttons inside the same header action group. Both mouse clicks and
Enter presses are stopped from bubbling out of that group, so activating a header
action never toggles the panel underneath it.
With the builder API:
field()
.key('excludeSection')
.type('section')
.label('Exclude Filters')
.isExcludeSection(true)
// Akifilter's own flags travel through `extend()`; `isExcludeSection` and
// `selectableFields` predate it and keep their setters.
.extend({ enableImportCsv: true, enableImportXls: true })
.fields([field().key('excludeSku').type('text').label('Exclude SKU').build()])
.build();Localization
Section header action copy lives under the section.actions namespace
(importCsv, importXls, importCsvTooltip, importXlsTooltip). The tooltip
keys interpolate {{section}} with the section's label.
Field Labels
A field's label drives its form input. Two optional props override it for one
surface each, so the chip and the picker can say something different from the
input:
| Prop | Overrides | Falls back to |
| ----------------- | ---------------------------------------------- | ------------- |
| appliedLabel | The applied-filter chip (and its remove button's accessible name). | label → placeholder → key |
| visibilityLabel | The field's row in the "Select visible filters" modal. | label → placeholder → key |
const filterSchema: AkifilterSchema = [
{
key: 'orderNo',
type: 'text',
label: 'Order No', // form input
appliedLabel: 'Order number', // chip: "Order number: 1001"
visibilityLabel: 'Order reference no' // modal row
},
{
key: 'excludeSection',
type: 'section',
label: 'Exclude Filters',
isExcludeSection: true,
fields: [
{
key: 'excludeStatus',
type: 'text',
label: 'Status',
// The chip is rendered outside the section, so it carries the context
// the section header would otherwise provide.
appliedLabel: 'Excluded status'
}
]
}
];Notes:
Both props are optional. A field that sets neither behaves exactly as before, so adding them to one field never affects the rest of the schema.
Section children support them too, including inside exclude sections.
The visibility modal's search box matches
visibilityLabel,label,placeholderandkey, so an overridden field stays findable under either name.Neither prop affects the form input's own label or its accessible name — use
labelfor that.Storage is keyed by field
key, never by any label, so renaming or overriding labels does not invalidate persisted values or visibility selections.They are Akifilter's own props, not part of the shared form schema. With the fluent builder they travel through its generic
extend()hatch:import { field } from '@akinon/akiform-builder'; field() .key('orderNo') .type('text') .label('Order No') .extend({ appliedLabel: 'Order number' }) .build();The call is type-checked even though the builder knows nothing about filters: Akifilter declares its props on the builder's
FieldExtensionsinterface, andextend()accepts only declared keys — a typo or a wrong type is a compile error, and an application that wants to hang its own metadata on a field declares it the same way:declare module '@akinon/akiform-builder' { interface FieldExtensions { requestKey?: string; } }AkiformBuilderignores all of it — it renders the form label fromlabelalone.
See the With Distinct Labels Storybook story for a live example.
Multi-Value Filters and Switchable Filter Types
Tags input out of the box
A select field with mode: 'tags' and no options is a free-form multi-value
input: type a value, press Enter to turn it into a removable tag, repeat. The
applied chip joins the entries with , .
{
key: 'keywords',
type: 'select',
label: 'Keywords',
mode: 'tags',
options: []
}mode is part of the storage signature, so switching a field between single
and multi-value modes invalidates its persisted entry instead of restoring an
incompatible shape.
One field, several chips (appliedChips)
By default a field contributes one applied-filter chip. A field that owns several filters at once — a bucket per filter type, a range that reads better as two chips — can produce its own instead:
{
key: 'attributeName',
type: 'custom',
label: 'Attribute Name',
// Value shape is the field's own business, e.g.
// { mode: 'exact', buckets: { contains: ['a', 'b'], exact: ['c'] } }
appliedChips: ({ field, value, formValues }) => [
{ id: 'contains', label: 'Attribute Name (Contains)', value: 'a, b' },
{ id: 'exact', label: 'Attribute Name (Exact match)', value: 'c' }
],
// Return the value the field should hold after the chip is removed,
// or `null` to clear the field.
onRemoveAppliedChip: ({ field, value, id }) => dropBucket(value, id),
render: ({ control }) => <MyInput control={control} />
}| Prop | Behaviour |
| ---- | --------- |
| 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. |
| 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. |
id must be stable for the same logical entry and unique within the field. Chip
keys are <field key>::<chip id> internally and are split at the first
separator, so an id may contain :: — a field key may not.
appliedChips is only called while the field's own value is non-empty — the
emptiness rules that suppress a plain chip (null/undefined, a blank string,
an empty array) suppress these too. Use formValues to format the chips (a
unit or a mode held by a sibling field), not to derive them from a sibling while
this field holds nothing.
With the fluent builder both props travel the same route as the label
overrides: field().….extend({ appliedChips, onRemoveAppliedChip }).
Because every chip is derived from the field's form value, persistence comes
for free: Akifilter stores and rehydrates the composite value like any other
one, and Clear all clears the field like any other. Nothing has to be mirrored
into host state or written to storage by the application.
Declaring appliedChips also tells the storage layer that the field owns its
value shape, so a composite (object) value is rehydrated instead of being
rejected as schema drift — the same exemption type: 'custom' gets. Without the
prop, only custom fields may hold object values across a reload.
A filter whose type the user can switch
Some filters carry a type next to their value — contains / exact match /
starts with — where each type emits under its own request key and several types
can be active at the same time. The With Tag Type Selector Storybook story
implements the whole flow on top of the pieces above:
| Concern | Mechanism |
| ------- | --------- |
| The input (tags + the type switcher on its right) | A custom field. Bind it with useController({ control: renderProps.control, name }). |
| 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. |
| One chip per type, each with its own label | appliedChips. |
| Removing a single type | onRemoveAppliedChip, returning the value without that bucket (null when the last one goes). |
| Mapping buckets onto request keys | Host logic, since one logical filter can emit several keys at once. |
The flow the story demonstrates:
- With
Containsactive, enteringfilter aandfilter bproducesAttribute Name (Contains): filter a, filter b. - Switching the type leaves that bucket with its own chip and shows the new type's (empty) bucket in the input.
- Entering
filter caddsAttribute Name (Exact match): filter c, and the payload carries both request keys. - Switching back shows the earlier bucket in the input again.
- A page refresh restores every bucket and the active type.
