From 72f7f9d9b5967e8dc9bfe4af02b2345318297652 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 10 Mar 2026 10:27:53 -0700 Subject: [PATCH 1/4] Initial (under review) planning docs for FilterBuilder project --- docs/planning/filter-builder/PLAN.md | 503 +++++++++++++++++++++++ docs/planning/filter-builder/PROMPT.md | 92 +++++ docs/planning/filter-builder/RESEARCH.md | 186 +++++++++ 3 files changed, 781 insertions(+) create mode 100644 docs/planning/filter-builder/PLAN.md create mode 100644 docs/planning/filter-builder/PROMPT.md create mode 100644 docs/planning/filter-builder/RESEARCH.md diff --git a/docs/planning/filter-builder/PLAN.md b/docs/planning/filter-builder/PLAN.md new file mode 100644 index 000000000..57e045dfb --- /dev/null +++ b/docs/planning/filter-builder/PLAN.md @@ -0,0 +1,503 @@ +# FilterBuilder — Execution Plan + +## Overview + +FilterBuilder is a new panel-based Hoist component for constructing filters of arbitrary complexity. +It provides a visual query builder UI as an alternative to FilterChooser's compact OmniBox-style +input, supporting nested AND/OR groups with NOT negation, type-appropriate value editors, and +full integration with Hoist's filter binding, persistence, and favorites systems. + +## Key Design Decisions + +- **commitOnChange pattern**: When `true`, edits sync immediately to the bound target. When `false` + (default), changes accumulate in a working tree and the UI shows Apply/Cancel buttons in a bbar. +- **Bi-directional binding**: Binds to a `FilterBindTarget` (Store, Cube View) the same way + FilterChooser and GridFilterModel do. Multiple filter components on the same target stay in sync + via MobX reactions — no special interop needed. +- **NOT/negation per group**: Supported from the start. Requires adding `not?: boolean` to + `CompoundFilter` and `CompoundFilterSpec` (backwards-compatible). Hoist-core work scheduled + separately. +- **Card component for groups**: Each filter group renders as a collapsible `card` with intent-based + styling for visual differentiation (AND vs OR, negation). +- **Placeholder for empty state**: Uses Hoist's `placeholder` component. +- **No drag-and-drop in v1**: Up/down shift buttons for reordering. DnD deferred to v2 (separate + from the DnD library upgrade tracked in #3918). +- **Desktop-only v1**: Cross-platform model in `cmp/`, desktop component in `desktop/cmp/`. Mobile + variant planned for later. +- **Favorites in bbar**: Favorites menu lives in the bottom toolbar alongside Apply/Cancel/Clear. +- **Toolbox example**: Built incrementally alongside the component for iterative testing and + refinement via browser. + +## File Map + +``` +data/filter/ +├── FilterFieldSpec.ts RENAMED from BaseFilterFieldSpec.ts +├── CompoundFilter.ts MODIFIED: add `not` support +├── Types.ts MODIFIED: add `not` to CompoundFilterSpec + +cmp/filter/ +├── FilterBuilderModel.ts NEW: Core cross-platform model +├── FilterBuilderFieldSpec.ts NEW: Field spec subclass +├── index.ts MODIFIED: add exports +└── impl/ + ├── FilterGroupNode.ts NEW: Mutable working-tree group node + └── FilterRuleNode.ts NEW: Mutable working-tree rule node + +desktop/cmp/filter/ +├── FilterBuilder.ts NEW: Desktop component + sub-factories +├── FilterBuilder.scss NEW: Styles +└── index.ts MODIFIED: add exports + +../toolbox/client-app/src/ +├── desktop/tabs/grids/ (or similar location) +│ ├── FilterBuilderPanel.ts NEW: Toolbox example page +│ └── FilterBuilderPanelModel.ts NEW: Toolbox example model +└── desktop/AppModel.ts MODIFIED: register new tab +``` + +--- + +## Phase 0: Core Data Model Enhancements + +### 0a. Rename BaseFilterFieldSpec → FilterFieldSpec + +**Files**: `data/filter/BaseFilterFieldSpec.ts`, `cmp/filter/FilterChooserFieldSpec.ts`, +`cmp/grid/filter/GridFilterFieldSpec.ts` + +- Rename file and class. Update the two importing subclasses. +- No public export change needed — `BaseFilterFieldSpec` is not exported from `data/index.ts`. +- This establishes a clean hierarchy name: `FilterFieldSpec` (abstract base) with siblings + `FilterChooserFieldSpec`, `GridFilterFieldSpec`, and the new `FilterBuilderFieldSpec`. + +### 0b. Add `not` support to CompoundFilter + +**Files**: `data/filter/CompoundFilter.ts`, `data/filter/Types.ts` + +Add `not?: boolean` to `CompoundFilterSpec`: + +```typescript +interface CompoundFilterSpec { + filters: FilterLike[]; + op?: CompoundFilterOperator; + not?: boolean; // NEW — default false +} +``` + +In `CompoundFilter`: +- Add `readonly not: boolean` property (default `false`). +- Constructor: `this.not = !!not;` +- `getTestFn()`: Wrap result with negation when `this.not` is true. +- `equals()`: Include `other.not === this.not` in comparison. +- `toJSON()`: Include `not: true` in output only when true (omit when false for + backwards-compatibility). + +This is fully backwards-compatible — existing code never sets `not`, existing JSON payloads don't +include it, and `parseFilter()` will pass it through to the constructor when present. + +**Hoist-core**: Corresponding Java-side change (add `not` field to `CompoundFilter`) will be +scheduled as a separate task. Client-side filtering works immediately; server-side filtering for +NOT groups requires the core update. + +### 0c. Verify with lint and type-check + +```bash +yarn lint:code && npx tsc --noEmit +``` + +--- + +## Phase 1: Cross-Platform Model Layer + +All files in `cmp/filter/` — no desktop dependencies, fully cross-platform. + +### 1a. FilterBuilderFieldSpec + +**File**: `cmp/filter/FilterBuilderFieldSpec.ts` + +New sibling extending `FilterFieldSpec` (the renamed base). Adds builder-specific configuration: + +```typescript +interface FilterBuilderFieldSpecConfig extends FilterFieldSpecConfig { + defaultOperator?: FieldFilterOperator; // Pre-selected op for new rules on this field +} +``` + +Properties beyond base: +- `defaultOperator: FieldFilterOperator` — validated against `ops`, defaults to first available. + +`loadValuesFromSource()` implementation: Simple — loads all values from source, similar to +FilterChooserFieldSpec. Sets up a debounced MobX reaction tracking `source.lastUpdated` for +auto-refresh (when `enableValues` is true and source is available). + +**What it does NOT need** (specific to other consumers): +- `valueRenderer`, `valueParser`, `example` — FilterChooser-specific (typeahead rendering) +- `filterModel`, `renderer`, `inputProps`, `allValuesCount` — GridFilter-specific + +### 1b. Working-Tree Node Models + +**Files**: `cmp/filter/impl/FilterGroupNode.ts`, `cmp/filter/impl/FilterRuleNode.ts` + +These are mutable observable nodes representing the user's in-progress filter construction. They +extend `HoistBase` (not `HoistModel` — no loading support needed). + +**FilterGroupNode**: +``` +@observable op: 'AND' | 'OR' +@observable not: boolean +@observable.ref children: (FilterGroupNode | FilterRuleNode)[] + +addRule(fieldSpec?: FilterBuilderFieldSpec): FilterRuleNode +addGroup(): FilterGroupNode +removeChild(child): void +moveChild(child, direction: 'up' | 'down'): void +toFilter(): CompoundFilter | null — convert to immutable Filter +get isEmpty(): boolean +get isComplete(): boolean — all rules have field + op + value +``` + +**FilterRuleNode**: +``` +@observable field: string | null +@observable op: FieldFilterOperator | null +@observable value: any + +toFilter(): FieldFilter | null — convert to immutable FieldFilter +get isComplete(): boolean — field, op, and value all set +clear(): void +``` + +**Static factory**: `FilterGroupNode.fromFilter(filter: Filter): FilterGroupNode` — converts +an immutable `Filter` (or `CompoundFilter`) into a mutable working tree for editing. + +### 1c. FilterBuilderModel + +**File**: `cmp/filter/FilterBuilderModel.ts` + +Core model. Cross-platform, no desktop dependencies. + +``` +FilterBuilderModel extends HoistModel + +Constructor config: + bind?: FilterBindTarget + valueSource?: FilterValueSource — defaults to bind if it's a FilterValueSource + fieldSpecs?: (FilterBuilderFieldSpecConfig | string)[] + fieldSpecDefaults?: Partial + commitOnChange?: boolean — default false + initialValue?: Thunkable + initialFavorites?: Thunkable + maxGroupDepth?: number — default 3 + persistWith?: FilterBuilderPersistOptions + +Observable state: + @managed fieldSpecs: FilterBuilderFieldSpec[] + @observable.ref rootGroup: FilterGroupNode — mutable working tree + @bindable commitOnChange: boolean + @observable.ref favorites: Filter[] + persistFavorites: boolean — set by initPersist if successful + +Computed: + @computed get value(): Filter — rootGroup.toFilter() (immutable) + @computed get isDirty(): boolean — value differs from committed + @computed get isEmpty(): boolean — rootGroup has no children + +Binding (bi-directional, same pattern as FilterChooserModel): + Inbound reaction: track bind.filter → populate rootGroup via fromFilter() + Outbound: appendFilter(bind.filter?.removeFieldFilters(), value) → bind.setFilter() + +Actions: + addRule(parentGroup?, fieldSpec?) + addGroup(parentGroup?) + removeNode(node) + moveNode(node, direction: 'up' | 'down') + setGroupOp(group, op: 'AND' | 'OR') + setGroupNot(group, not: boolean) + apply() — commit working tree to target (outbound sync) + cancel() — revert working tree to last committed state + clear() — empty the working tree + reset() — alias for cancel + +Favorites (matching FilterChooserModel pattern): + addFavorite(filter?) — defaults to current value + removeFavorite(filter) + setFavorites(favorites) + isFavorite(filter): boolean + get favoritesOptions + +Persistence (split-store capable, same pattern as FilterChooserModel): + persistValue?: boolean | PersistOptions + persistFavorites?: boolean | PersistOptions + Separate PersistenceProvider instances for value and favorites +``` + +**commitOnChange behavior**: +- When `true`: Each mutation to the working tree triggers an immediate `apply()`. +- When `false`: Mutations accumulate. UI shows Apply/Cancel in bbar. Apply commits; Cancel reverts. + +**Inbound sync detail**: When `bind.filter` changes externally (e.g., FilterChooser or +GridFilterModel updates the same Store), the reaction rebuilds the working tree via +`FilterGroupNode.fromFilter()`. When `commitOnChange` is false and the user has uncommitted edits +(isDirty), we need to decide: overwrite or warn. Initial implementation: overwrite silently when +not dirty, preserve working tree when dirty (log a warning). + +### 1d. Exports + +**File**: `cmp/filter/index.ts` — add exports for `FilterBuilderModel` and +`FilterBuilderFieldSpec`. + +### 1e. Verify + +```bash +yarn lint:code && npx tsc --noEmit +``` + +--- + +## Phase 2: Desktop Component + +### 2a. FilterBuilder Component + +**File**: `desktop/cmp/filter/FilterBuilder.ts` + +Main component created via `hoistCmp.withFactory()`, exporting both `FilterBuilder` and +`filterBuilder` (element factory). Uses `model: uses(FilterBuilderModel)`. + +**Component tree** (internal sub-component factories): + +``` +FilterBuilder (main) +│ +├── [empty state] placeholder(Icon.filter(), 'Add a filter to get started.') +│ — shown when rootGroup has no children +│ +├── filterGroupCard (recursive) +│ │ +│ │ Renders as: card({ +│ │ title: groupHeader, — AND/OR selector + NOT toggle + Add Rule + Add Group + Remove +│ │ icon: ..., +│ │ intent: ..., — visual differentiation (AND vs OR, negated) +│ │ modelConfig: {collapsible: true}, +│ │ items: children +│ │ }) +│ │ +│ ├── filterRuleRow +│ │ └── hbox: [field select] [op select] [value editor] [↑] [↓] [×] +│ │ +│ └── filterGroupCard (nested — recursive, depth-limited by maxGroupDepth) +│ +├── filterRuleRow +│ ├── Field selector: select({options: fieldSpecs, ...}) +│ ├── Operator selector: select({options: spec.ops, ...}) +│ ├── Value editor: valueEditor (type-mapped, see below) +│ ├── Shift up button: button({icon: Icon.chevronUp(), ...}) +│ ├── Shift down button: button({icon: Icon.chevronDown(), ...}) +│ └── Remove button: button({icon: Icon.delete(), ...}) +│ +├── valueEditor (type-mapped sub-factory) +│ ├── string + enumerable values → select (with value options from fieldSpec) +│ ├── string + free text → textInput +│ ├── int / number → numberInput +│ ├── date / localDate → dateInput +│ ├── bool → switchInput +│ ├── tags → select({enableMulti: true}) +│ └── = / != with enumerable → select (single or multi based on op) +│ +├── bbar (toolbar) +│ ├── [commitOnChange=false]: button('Apply') + button('Cancel') +│ ├── button('Clear') +│ ├── filler() +│ └── favoritesButton → opens favoritesMenu +│ +└── favoritesMenu (popover, matching FilterChooser pattern) + ├── List of saved favorites (click to load) + ├── "Add current" menu item + └── Delete button per favorite +``` + +**Group header layout** (inside card title area): + +``` +[AND/OR toggle] [NOT checkbox] [spacer] [+ Rule] [+ Group] [× Remove] +``` + +The AND/OR control could be a `buttonGroupInput` or a small `select`. NOT is a `checkbox` or +`switchInput`. Action buttons are icon-only `button` components. + +**Depth limiting**: The "Add Group" button is hidden when the current group is at `maxGroupDepth`. + +**Scrolling**: The FilterBuilder's content area should be scrollable. The root-level bbar stays +fixed at the bottom. Consider `overflow: 'auto'` on the content container. + +### 2b. FilterBuilder Styles + +**File**: `desktop/cmp/filter/FilterBuilder.scss` + +```scss +.xh-filter-builder { + // Root container — flex column + // Scrollable content area + // Fixed bbar at bottom + + &__group { + // Card-based group container + // Indentation via nested cards (natural) + // Colored left border or intent per group type + } + + &__group-header { + // AND/OR selector + NOT toggle + action buttons + // Flexbox row, center-aligned + } + + &__rule { + // Horizontal row: field + op + value + actions + // Flexbox row with gap + // Consistent field widths for alignment + } + + &__rule-field { /* Fixed or flex width for field selector */ } + &__rule-op { /* Narrower width for operator selector */ } + &__rule-value { /* Flex-grow for value editor */ } + &__rule-actions { /* Fixed width for shift + remove buttons */ } + + &__bbar { + // Bottom toolbar with apply/cancel/clear/favorites + } +} +``` + +### 2c. Exports + +**File**: `desktop/cmp/filter/index.ts` — add `FilterBuilder`, `filterBuilder` exports. + +### 2d. Verify + +```bash +yarn lint && npx tsc --noEmit +``` + +--- + +## Phase 3: Toolbox Example + Iterative Refinement + +### 3a. Create Toolbox Example Page + +**Files**: In `../toolbox/client-app/src/desktop/tabs/` (exact location TBD based on existing tab +structure). + +The example page should demonstrate: + +1. **Basic usage**: FilterBuilder bound to a Store, with a Grid showing filtered results. + Various field types (string, number, date, bool, tags) to exercise all value editors. + +2. **commitOnChange comparison**: Side-by-side or toggle between `commitOnChange: true` and + `false` to show the difference in UX. + +3. **Favorites**: Persistence enabled, demonstrating save/load/delete of favorite filters. + +4. **Companion with FilterChooser**: Both FilterBuilder and FilterChooser bound to the same + Store, demonstrating bi-directional sync. + +5. **Nested groups**: Pre-populated example with nested AND/OR/NOT groups to show the full + capability. + +### 3b. Browser Testing + +Use the Toolbox example to iteratively test and refine: +- Adding/removing rules and groups +- AND/OR toggling and NOT negation +- Value editors for each field type +- Shift up/down reordering +- Collapsible groups (expand/collapse) +- Apply/Cancel workflow (commitOnChange=false) +- Live sync (commitOnChange=true) +- Favorites save/load/delete +- Bi-directional sync with FilterChooser +- Empty state placeholder +- Scrolling with many rules +- Various container sizes + +### 3c. Iterative Refinement + +Based on browser testing, refine: +- Layout proportions and spacing +- Select component widths and behavior +- Group visual differentiation (colors, borders) +- Keyboard navigation and tab order +- Edge cases (empty values, invalid states, deeply nested groups) + +--- + +## Phase 4: Polish and Documentation + +### 4a. Final Verification + +```bash +yarn lint && npx tsc --noEmit +``` + +Review all new files for: +- Consistent Hoist coding conventions +- Proper `@managed` decorators on child models +- Proper `@observable` / `@action` usage +- Named exports (no default exports) +- `null` over `undefined` as no-value sentinel + +### 4b. Documentation + +- Update `cmp/filter/` README (or create one) covering FilterBuilder alongside FilterChooser +- Update `docs/README.md` index if needed +- Run xh-update-doc-links for consistency + +### 4c. Commit Strategy + +Commits organized by phase: +1. Phase 0: "Rename BaseFilterFieldSpec, add CompoundFilter NOT support" +2. Phase 1: "Add FilterBuilderModel and supporting classes" +3. Phase 2: "Add desktop FilterBuilder component" +4. Phase 3: "Add Toolbox FilterBuilder example" +5. Phase 4: "FilterBuilder polish and documentation" + +--- + +## Deferred (v2+) + +- **Drag-and-drop reordering** — after DnD library upgrade (#3918) is merged +- **Mobile FilterBuilder variant** — `mobile/cmp/filter/FilterBuilder.ts` +- **FunctionFilter support** — curated library supplied by developer +- **Lock/disable individual rules** +- **Clone rules/groups** +- **"Quick filter" bar mode** — simplified single-level variant + +--- + +## Key Reference Files + +### Hoist Filter System +- `data/filter/Filter.ts` — Abstract base +- `data/filter/FieldFilter.ts` — Value-based filtering (14 operators) +- `data/filter/CompoundFilter.ts` — AND/OR grouping (+ NOT after Phase 0) +- `data/filter/FunctionFilter.ts` — Custom test functions (not serializable) +- `data/filter/Types.ts` — FilterBindTarget, FilterValueSource, FilterSpec interfaces +- `data/filter/Utils.ts` — parseFilter, appendFilter, flattenFilter + +### Existing Filter Components (primary references) +- `cmp/filter/FilterChooserModel.ts` — Binding, persistence, favorites patterns +- `cmp/filter/FilterChooserFieldSpec.ts` — Field spec with value rendering/parsing +- `cmp/grid/filter/GridFilterModel.ts` — commitOnChange pattern, per-column filtering +- `cmp/grid/filter/GridFilterFieldSpec.ts` — Field spec with dynamic value filtering +- `desktop/cmp/filter/FilterChooser.ts` — Component structure, favorites UI + +### Reusable Components +- `cmp/card/Card.ts` + `CardModel.ts` — Collapsible container for groups +- `cmp/layout/Placeholder.ts` — Empty state display + +### Persistence +- `core/persist/PersistenceProvider.ts` — Factory, merge, read/write +- `docs/persistence.md` — Canonical persistence reference + +### Prior Art +- [react-querybuilder](https://react-querybuilder.js.org/) — Architecture reference +- [react-awesome-query-builder](https://github.com/ukrbublik/react-awesome-query-builder) +- [Syncfusion Query Builder](https://www.syncfusion.com/react-components/react-query-builder) diff --git a/docs/planning/filter-builder/PROMPT.md b/docs/planning/filter-builder/PROMPT.md new file mode 100644 index 000000000..3cc23a6be --- /dev/null +++ b/docs/planning/filter-builder/PROMPT.md @@ -0,0 +1,92 @@ +# FilterBuilder Project + +## Prompt + +We need to build a new component called FilterBuilder with a backing FilterBuilderModel. + +As the name implies, this component is dedicated to viewing, editing, and creating Hoist filters, and should be inspired on the model side in large part by FilterChooserModel, which will be a natural companion along with the GridFilterModel. Review the hoist documentation, data package, and these components thoroughly to understand how they work and interrelate, and how filters in general are constructed and operate in hoist. + +This new component will provide an alternate UI for filter construction that is panel-based, so not attempting to fit into grid headers or a single OmniBox-style input, but instead taking the space needed to allow the user to construct filters of arbitrary complexity and depth, including compound filters with multiple clauses. + +It can be heavily inspired by React Query Builder and similar prior art, and researching this and coming up with a set of best practices that mesh well with Hoist filtering abilities is a key part of the project. + +In all ways, this should be a first-class idiomatic Hoist component using Hoist componentry, utilities, and patterns all the way down. + +Like FilterChooserModel, it should support persistence. The ability to save and reload favorite filters is also desirable for feature parity. + +This component could also allow us to support function filters, but they would need to somehow be selected from a curated library supplied by the developer. That seems like an advanced feature that we could bucket for a Phase 2 and deliberately NOT include in the initial implementation, which can deal with FieldFilters and CompoundFilters only, much as filter chooser does. + +We should ensure that the component's internals scroll smoothly and are usable across a variety of potential sizes, as this component will be nested into various areas of different application UIs depending on their specific needs. + +It should almost certainly include a drag-and-drop ability to reorder or re-nest clauses, as that is a common operation and users likely expect dnd functionality. Research prior art / other library comparisons, and let's decide before we begin implementation if this is an essential v1 feature or would be deferred for later v2 work. Note that we have a GitHub issue around a pending upgrade to the drag and drop library we use. It might be beneficial for us to complete that task before we implement any new drag and drop work in the framework here. + +Consider all of these factors, research Library comparisons, investigate the drag and drop angle, then come up with a list of any questions or clarifications you require so that we can map out a formal execution plan and begin work. + +------- + +## Follow-up questions 1 + +1. Working-tree vs. live-edit model: Should changes be "staged" (user edits a working copy, then clicks Apply to + commit the filter)? Or should each individual edit immediately update the bound filter? FilterChooser uses + immediate binding. A staged approach is more typical for query builders and prevents partial/invalid filters + from being applied mid-edit. My recommendation: staged with Apply/Cancel for v1. +2. Relationship to FilterChooser: Should FilterBuilderModel be usable as a companion to FilterChooserModel (both + bound to the same target, staying in sync)? Or should it be a standalone alternative? The companion approach + seems powerful — users could use the OmniBox for quick filtering and switch to the builder for complex + construction. If so, should they share a FilterChooserFieldSpec config, or should FilterBuilder have its own + field spec type? +3. File organization: I'd place the model in cmp/filter/ alongside FilterChooserModel and the desktop component + in desktop/cmp/filter/. The internal working-tree nodes would go in cmp/filter/impl/. Sound right? +4. Operator restrictions per field type: FilterChooserModel's FilterChooserFieldSpec already has ops + configuration (allowed operators per field). Should FilterBuilder reuse this directly, or do we need more + granular control (e.g., different default operator sets for the builder UI vs. the chooser)? +5. DnD upgrade sequencing: Should we merge the DnD library upgrade (branch 3918) into develop now as a + prerequisite, even though FilterBuilder v1 won't use DnD? Getting it merged sooner reduces future merge + conflicts and addresses the deprecation. +6. Mobile support: Should we plan for a mobile/cmp/filter/FilterBuilder variant, or is this desktop-only for + now? +7. Toolbar/action bar: Should the component include a built-in toolbar with Apply/Cancel/Clear/Reset buttons, or + should those be left to the consuming app to provide? (I'd lean toward a configurable built-in bbar with + reasonable defaults.) +8. "Interop" with GridFilterModel: GridFilterModel manages per-column filters. Should FilterBuilder be able to + read/display filters that were set via grid column headers, and vice versa? This seems valuable but adds + complexity — could be a v1.5 feature. + + +### Answers + +1. Implement with Hoist `commitOnChange` pattern. If false, show apply/cancel buttons in filterBuilder UI. If true, sync immediately to bound target. +2. As with existing FilterChooser + GridFilter, if they are bound to the same target the bi-directional binding should do its thing and they should be in perfect sync. The question of the field spec interface is an interesting one - we should examine if the `FilterChooserFieldSpec` has knobs in it that would not apply here, or vice-versa. Very open to a parent interface to span both, or adjusting the name if needed. We should consider this holistically along with the grid filtering package. +3. Mirroring FilterChooser prior art a good plan. We should be careful to allow future addition of a mobile-specific UI. Anything in root (cross-platform) /cmp must be truly cross-platform. Anything that uses desktop components needs to be in desktop/cmp. +4. Operator handling seems like it should be the same. We should support the full set of expected operators. +5. We'll review the Drag-and-Drop upgrade sequencing, but if we're not using Drag-and-Drop in this project, then it's a completely separate concern. +6. We should plan for a mobile variant, but we should build one later after we have a working cross-platform model and desktop-specific implementation. +7. Yes, agreed on the B-Bar. I think this is where favourite support could also live. See above note regarding commit on change. +8. Ensure you understand the integration point where we're syncing to a filtered target, such as a store, cube view, or actual filter. That's how the bidirectional binding and state sharing works. Your question about interop with grid filter model makes me question whether or not you fully understand the pattern. + +Other items: +- Note that the empty stage should use our placeholder component. +- I would like to enable not or negation per group. That sounds like a useful feature that should be possible to build in and would be best to consider from the start. +- I'd like to leverage our collapsible card and/or field set component for the collapsible groups. It feels like that card component with its built-in collapsibility is an excellent candidate for holding clause-level information. This is not an absolute necessity, but it feels like a good re-use story that should work. + + +------ + +## Follow-up questions 2 + +1. Renaming BaseFilterFieldSpec → FilterFieldSpec — This is clean (only 2 importers) but touches the data/ + package. Want to do it now as part of this work, or defer? +2. CompoundFilter not support — Adding this to the core data model is the cleanest approach and enables + round-trip serialization. However, it would need corresponding support in hoist-core (Java side) to be fully + useful for server-side filtering. Should we proceed, or implement negation purely at the UI/working-tree level + for now (losing the not flag on save/reload)? +3. Toolbox example — Should the Toolbox example be part of this work, or handled separately? +4. Ready to begin implementation? If so, I'd start with Phase 0 + Phase 1 (data model enhancements + model), + then Phase 2 (component), then Phase 3 (polish). + +### Answers + +1. Fine on that rename +2. CompoundFilter not support - explain further if that's a core feature of other prior art you researched, and expand a bit more on how it would work. I am open to the idea and we can schedule the hoist-core work easily enough, just want to understand the value proposition or how it might be an expectation users of the other libraries could have +3. Toolbox example is a great idea - and essential to testing this. Absolutely make that part of the plan, building it as we go so you can then use it to iteratively refine the UI and test functionality by driving it in the browser +4. Incorporate the above, then I have one process question before we start. diff --git a/docs/planning/filter-builder/RESEARCH.md b/docs/planning/filter-builder/RESEARCH.md new file mode 100644 index 000000000..02f1f0732 --- /dev/null +++ b/docs/planning/filter-builder/RESEARCH.md @@ -0,0 +1,186 @@ +# FilterBuilder — Research Notes + +Supporting context for PLAN.md. Summarizes findings from the research phase. + +## Prior Art: Query Builder Libraries + +### react-querybuilder (most popular, ~200k weekly npm downloads) + +- Modular architecture: core + optional DnD package + UI library compatibility packages. +- Data model: recursive `{combinator, not, rules}` where rules contain both rule objects and nested + group objects. Maps cleanly to Hoist's CompoundFilter/FieldFilter hierarchy. +- Key UX patterns: + - `maxGroupDepth` prop limits nesting to prevent overly complex queries. + - `showShiftActions` provides up/down reordering buttons without DnD. + - `showNotToggle` enables per-group NOT negation. + - `showCombinatorsBetweenRules` renders AND/OR inline between rules. +- DnD is a separate optional package (`@react-querybuilder/dnd`), confirming that DnD is not + essential for v1. +- NOT support is a standard, opt-in feature. + +### react-awesome-query-builder (~31k weekly downloads) + +- Richer out-of-box UI with multiple framework skins. +- Uses immutable tree internally (heavier state management). +- Built-in DnD, more complex configuration. +- Also supports NOT per group. + +### Syncfusion React Query Builder (commercial) + +- Professional polish, built-in DnD, header/rule/value templates. +- Also supports NOT conditions with a toggle in group headers. + +### Key UX Best Practices (from research) + +- **Limit nesting depth**: Default 2-3 levels, configurable. Most real queries rarely need more. +- **Visual hierarchy**: Colored left-border bars and indentation per nesting level. +- **AND/OR visualization**: Combinator shown in group header, with color differentiation. +- **Empty state**: Actionable CTA ("Add a filter to get started") with icon. +- **Value editors**: Type-mapped inputs (string→text/select, number→numberInput, date→dateInput). +- **DnD is universally optional**: Every major library treats it as enhancement, not requirement. + +## Field Spec Hierarchy Analysis + +### Current Structure + +``` +BaseFilterFieldSpec (abstract, in data/filter/) +├── FilterChooserFieldSpec (in cmp/filter/) +└── GridFilterFieldSpec (in cmp/grid/filter/) +``` + +### BaseFilterFieldSpec — Shared Core + +Properties: `field`, `fieldType`, `displayName`, `ops`, `source`, `enableValues`, `forceSelection`, +`values`, `hasExplicitValues`. + +Key methods: `filterType` getter (range/value/collection), `loadValues()`, `supportsOperator()`, +`supportsSuggestions()`, abstract `loadValuesFromSource()`. + +### FilterChooserFieldSpec — Adds + +- `valueRenderer` — custom display formatting for typeahead tags +- `valueParser` — parse user text input into typed values +- `example` — sample value shown in UI help +- Auto-loading: MobX reaction tracks `source.lastUpdated`, debounced 100ms + +These are all specific to the typeahead/OmniBox interaction model. + +### GridFilterFieldSpec — Adds + +- `filterModel` — reference to owning GridFilterModel +- `renderer` — column-style value rendering for filter values tab +- `inputProps` — props for HoistInput in custom filter tab +- `defaultOp` — pre-selected operator in custom tab +- `allValuesCount` — total unique values for UI indication +- Complex `loadValuesFromSource()` — considers other active column filters for dynamic filtering + +These are all specific to the grid column filter interaction model. + +### FilterBuilderFieldSpec — Will Add + +- `defaultOperator` — pre-selected operator for new rules +- Simple `loadValuesFromSource()` with auto-loading reaction (like FilterChooserFieldSpec) +- Does NOT need valueRenderer/valueParser/example (not a typeahead) or filterModel/renderer/ + inputProps (not a grid column filter) + +### Key Finding + +Zero property overlap between the two existing subclasses. Each is genuinely specialized for its +consumer's interaction model. FilterBuilder should follow this pattern as a third sibling, not +reuse either existing spec. + +## FilterBindTarget — Bi-directional Binding Pattern + +### Interface + +```typescript +interface FilterBindTarget { + filter: Filter; // Observable property + setFilter(filter: FilterLike): unknown; // Update method +} +``` + +### How Components Stay in Sync + +Multiple filter components (FilterChooser, GridFilterModel, FilterBuilder) all independently bind +to the same target (Store, Cube View). The target IS the integration point. + +**Inbound sync**: MobX reaction tracks `bind.filter`. When target changes from any source, each +component updates its internal state. + +**Outbound sync**: Component calls `appendFilter(bind.filter?.removeFieldFilters(), newValue)` then +`bind.setFilter()`. The "selective removal + append" pattern preserves FunctionFilters from other +components while replacing FieldFilters. + +**Loop prevention**: Three layers: +1. Store's `setFilter()` checks `filter.equals()` — no-op if same. +2. Model's setValue checks `value.equals()` — no-op if same. +3. Async batching via `wait()` prevents mid-render conflicts. + +### commitOnChange Pattern + +`GridFilterModel` has a `commitOnChange` flag (default `false`). FilterBuilder will implement this +fully: +- `true`: Each working-tree mutation triggers immediate `apply()` → `bind.setFilter()`. +- `false`: Mutations accumulate. UI shows Apply/Cancel. Apply commits; Cancel reverts to last + committed state. + +## CompoundFilter NOT Support + +### Prior Art + +NOT/negation per group is supported by all major query builder libraries: +- react-querybuilder: `not: boolean` on each group, opt-in via `showNotToggle` +- react-awesome-query-builder: NOT button per group +- Syncfusion: NOT conditions toggle in group header + +It's essentially universal and expected by users familiar with other query builders. + +### Value Proposition + +NOT is hard to express by inverting individual operators. Consider: "Show everyone NOT in +(Engineering AND hired after 2020)". Inverting via De Morgan's law changes the group structure and +loses the user's mental model. NOT preserves the user's intent and enables clean "exclude this +combination" scenarios. + +### Implementation in Hoist + +Small, backwards-compatible change to `CompoundFilter`: +- Add `readonly not: boolean` (default `false`). +- `getTestFn()`: Wrap result with `!` when `not` is true. +- `toJSON()`: Include `not: true` only when true (omit when false). +- `equals()`: Include `not` in comparison. + +Existing code never sets `not`, so behavior is unchanged. Existing JSON payloads don't include it. + +Hoist-core (Java): Add `not` field to `CompoundFilter` — small, backwards-compatible change. +Scheduled as separate task. + +## Persistence Pattern + +FilterBuilderModel will follow FilterChooserModel's persistence pattern exactly: +- Two separate `PersistenceProvider` instances for value and favorites. +- Different paths: `filterBuilder.value` and `filterBuilder.favorites`. +- Split-store capable: value and favorites can use different providers. +- `persistFavorites` boolean flag gating UI visibility. +- `FilterBuilderPersistOptions extends PersistOptions` with `persistValue` and `persistFavorites`. + +## Reusable Components + +### Placeholder (`cmp/layout/Placeholder.ts`) + +Styled empty-state container. Flexbox column, centered, muted text. First child Icon auto-styled +large (4em) and semi-transparent. No model required. + +### Card (`cmp/card/Card.ts` + `CardModel.ts`) + +Bordered container with optional header (title + icon), intent-based styling (primary/success/ +warning/danger border and header colors), and built-in collapsibility via `CardModel`. Uses +`
` + `` semantically. Supports persistence of collapsed state. + +For FilterBuilder groups: +- Title area hosts AND/OR selector + NOT toggle + action buttons. +- Content area hosts child rules and nested groups. +- Collapsible for complex queries. +- Intent colors for visual differentiation (AND=primary, OR=different, negated=warning/danger). From 4ccab4d943d5aff3cdf7e23d14e6630b13682577 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Tue, 10 Mar 2026 15:13:22 -0700 Subject: [PATCH 2/4] Add FilterBuilder component for visual filter construction New panel-based component for constructing filters of arbitrary complexity. Provides a visual query builder UI with nested AND/OR groups, NOT negation, type-appropriate value editors, favorites, persistence, and full integration with Hoist's filter binding system. Key additions: - FilterBuilderModel (cross-platform) with mutable working tree, apply/cancel workflow, bi-directional binding, and favorites - FilterBuilderFieldSpec extending renamed FilterFieldSpec (was BaseFilterFieldSpec) - Desktop FilterBuilder component with recursive group cards, type-mapped value editors, and favorites menu - CompoundFilter `not` support for negated filter groups - cmp/filter/README.md documenting both FilterChooser and FilterBuilder - CHANGELOG, doc index, doc registry, and roadmap updates Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 + CLAUDE.md | 22 + cmp/README.md | 2 +- cmp/filter/FilterBuilderFieldSpec.ts | 44 ++ cmp/filter/FilterBuilderModel.ts | 437 ++++++++++++++++++ cmp/filter/FilterChooserFieldSpec.ts | 9 +- cmp/filter/README.md | 202 ++++++++ cmp/filter/impl/FilterGroupNode.ts | 133 ++++++ cmp/filter/impl/FilterRuleNode.ts | 68 +++ cmp/filter/index.ts | 2 + cmp/grid/filter/GridFilterFieldSpec.ts | 9 +- data/README.md | 2 +- data/filter/CompoundFilter.ts | 20 +- ...eFilterFieldSpec.ts => FilterFieldSpec.ts} | 6 +- data/filter/Types.ts | 3 + desktop/cmp/filter/FilterBuilder.scss | 61 +++ desktop/cmp/filter/FilterBuilder.ts | 402 ++++++++++++++++ desktop/cmp/filter/index.ts | 1 + docs/README.md | 2 + docs/doc-registry.json | 8 + docs/planning/docs-roadmap-log.md | 20 + docs/planning/docs-roadmap.md | 1 + 22 files changed, 1436 insertions(+), 24 deletions(-) create mode 100644 cmp/filter/FilterBuilderFieldSpec.ts create mode 100644 cmp/filter/FilterBuilderModel.ts create mode 100644 cmp/filter/README.md create mode 100644 cmp/filter/impl/FilterGroupNode.ts create mode 100644 cmp/filter/impl/FilterRuleNode.ts rename data/filter/{BaseFilterFieldSpec.ts => FilterFieldSpec.ts} (97%) create mode 100644 desktop/cmp/filter/FilterBuilder.scss create mode 100644 desktop/cmp/filter/FilterBuilder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2877ca8b..7bbc4e681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### 🎁 New Features +* Added `FilterBuilder` — a new panel-based component for constructing filters of arbitrary + complexity. Provides a visual query builder UI with nested AND/OR groups, NOT negation, + type-appropriate value editors, favorites, and full integration with Hoist's filter binding + system. Includes cross-platform `FilterBuilderModel` and desktop `FilterBuilder` component. +* Added `not` support to `CompoundFilter`, enabling negated filter groups (e.g. NOT (A AND B)). + Backwards-compatible — existing filters are unaffected. * Added publish controls to the Admin Metrics tab, supporting the new opt-in metrics export feature in hoist-core 36.4. diff --git a/CLAUDE.md b/CLAUDE.md index 33a312787..80fc9a487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,6 +176,13 @@ All Hoist artifacts extend `HoistBase`, which provides: (`model.myProp = value`) over calling the generated setter (`model.setMyProp(value)`). - `@computed` MobX decorator - Marks getter properties as derived/computed state +**IMPORTANT: `makeObservable(this)` requirement** — Any `HoistModel` or `HoistService` subclass +that declares `@observable`, `@bindable`, `@computed`, or `@action` members **must** call +`makeObservable(this)` in its constructor. Omitting this call silently breaks reactivity and +produces a runtime console warning: *"Observable properties not initialized properly. Ensure you +call makeObservable(this) in your constructor."* This is easy to miss when you are not monitoring +the browser console. When creating or modifying model classes, always verify this call is present. + #### Memory/lifecycle Management Conventions - `@managed` decorator - marks child objects for automatic cleanup - apply to properties holding @@ -206,6 +213,21 @@ wrappers and custom SCSS that duplicate what Hoist already provides. Components in `/desktop/` and `/mobile/` are platform-specific. Shared code lives in `/cmp/`, `/core/`, `/data/`, and `/svc/`. +## Interactive Debugging with Chrome + +When using browser automation tools (e.g. Chrome MCP) to interactively test Hoist components: + +- **Always check the browser console** after significant interactions — runtime errors and warnings + are invisible in screenshots but often critical. Look for MobX strict-mode violations, uncaught + promise rejections, React key/prop warnings, and Hoist-specific diagnostics. Surface any console + errors to the developer immediately rather than continuing to test against broken state. +- **Use `data-testid` attributes** for reliable element targeting. Hoist components support a + `testId` prop (via `TestSupportProps`) that renders as `data-testid` in the DOM. Use `getTestId()` + to create hierarchical IDs for sub-elements (e.g. `getTestId(testId, 'add-rule')`). Prefer + testId-based selectors over fragile coordinate or text-based targeting. +- **Watch for HMR state loss** — hot module replacement during development resets model state. After + code changes, a full page reload may be needed to get a clean baseline before testing. + ## Code Style For the full conventions reference — import organization, class structure, component patterns, diff --git a/cmp/README.md b/cmp/README.md index 15b6cb542..92b93b05f 100644 --- a/cmp/README.md +++ b/cmp/README.md @@ -174,7 +174,7 @@ techniques. | Sub-package | Description | |-------------|-------------| -| `/filter/` | FilterChooserModel for building filter UIs | +| `/filter/` | FilterChooserModel and FilterBuilderModel for building filter UIs. [See README](./filter/README.md) | | `/grouping/` | GroupingChooserModel for dimension selection | | `/store/` | Store-related UI components (count label, filter field) | diff --git a/cmp/filter/FilterBuilderFieldSpec.ts b/cmp/filter/FilterBuilderFieldSpec.ts new file mode 100644 index 000000000..94d1c52a0 --- /dev/null +++ b/cmp/filter/FilterBuilderFieldSpec.ts @@ -0,0 +1,44 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import {FilterFieldSpec, FilterFieldSpecConfig} from '@xh/hoist/data/filter/FilterFieldSpec'; +import {FieldFilterOperator} from '@xh/hoist/data/filter/Types'; + +export interface FilterBuilderFieldSpecConfig extends FilterFieldSpecConfig { + /** Pre-selected operator for new rules on this field. Defaults to first available. */ + defaultOperator?: FieldFilterOperator; +} + +/** + * Filter field specification class for the FilterBuilder component. Extends the base + * FilterFieldSpec with builder-specific configuration. + * + * Apps should NOT instantiate this class directly. Instead see {@link FilterBuilderModel.fieldSpecs} + * for the relevant config to set these options. + */ +export class FilterBuilderFieldSpec extends FilterFieldSpec { + defaultOperator: FieldFilterOperator; + + /** @internal */ + constructor({defaultOperator, ...rest}: FilterBuilderFieldSpecConfig) { + super(rest); + + this.defaultOperator = this.ops.includes(defaultOperator) ? defaultOperator : this.ops[0]; + + if (!this.hasExplicitValues && this.source && this.sourceField && this.enableValues) { + this.addReaction({ + track: () => this.source.lastUpdated, + run: () => this.loadValues(), + debounce: 100, + fireImmediately: true + }); + } + } + + loadValuesFromSource() { + this.values = this.source.getValuesForFieldFilter(this.field); + } +} diff --git a/cmp/filter/FilterBuilderModel.ts b/cmp/filter/FilterBuilderModel.ts new file mode 100644 index 000000000..981034786 --- /dev/null +++ b/cmp/filter/FilterBuilderModel.ts @@ -0,0 +1,437 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import { + HoistModel, + managed, + PersistableState, + PersistenceProvider, + PersistOptions, + Thunkable +} from '@xh/hoist/core'; +import { + appendFilter, + Filter, + FilterBindTarget, + FilterValueSource, + isFilterValueSource, + parseFilter +} from '@xh/hoist/data'; +import {FilterLike} from '@xh/hoist/data/filter/Types'; +import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {executeIfFunction, throwIf} from '@xh/hoist/utils/js'; +import {isObject, isString} from 'lodash'; + +import {FilterBuilderFieldSpec, FilterBuilderFieldSpecConfig} from './FilterBuilderFieldSpec'; +import {FilterGroupNode} from './impl/FilterGroupNode'; + +export interface FilterBuilderConfig { + /** + * Specifies the fields this model supports for filtering. + * If a `valueSource` is provided, these may be specified as field names in that source + * or omitted entirely, indicating that all fields should be filter-enabled. + */ + fieldSpecs?: Array; + + /** Default properties to be assigned to all FilterBuilderFieldSpecs created by this model. */ + fieldSpecDefaults?: Partial; + + /** + * Target (typically a {@link Store} or Cube {@link View}) to which this model's filter should + * be automatically applied as it changes. + * + * Note this binding is bi-directional - the target's filter will also be *set onto* this model + * if it changes on the target, to support sync'd filtering between a FilterBuilder and + * other filter components bound to the same target. + */ + bind?: FilterBindTarget; + + /** + * Source (typically a {@link Store} or Cube {@link View}) from which this model can lookup + * matching Field-level defaults for `fieldSpecs` and provide value suggestions. + * Defaults to {@link bind} if bind is a valid source. + */ + valueSource?: FilterValueSource; + + /** + * Initial filter value, or a function to produce one. + */ + initialValue?: Thunkable; + + /** + * Initial favorites as an array of filter configs, or a function to produce such an array. + */ + initialFavorites?: Thunkable; + + /** + * True to immediately commit changes to the bound target on every edit. + * When false (default), changes accumulate and require an explicit `apply()` call. + */ + commitOnChange?: boolean; + + /** Maximum depth of nested groups. Default 3. */ + maxGroupDepth?: number; + + /** Options governing persistence. */ + persistWith?: FilterBuilderPersistOptions; +} + +export class FilterBuilderModel extends HoistModel { + bind: FilterBindTarget; + valueSource: FilterValueSource; + maxGroupDepth: number; + persistFavorites: boolean = false; + + @managed fieldSpecs: FilterBuilderFieldSpec[] = []; + @observable.ref rootGroup: FilterGroupNode; + @bindable commitOnChange: boolean; + @observable.ref favorites: Filter[] = []; + + // Track the last committed value for dirty-checking and cancel/revert + @observable.ref private committedValue: Filter = null; + + // Flag to suppress outbound sync during inbound updates + private _suppressSync = false; + + @computed + get value(): Filter { + return this.rootGroup?.toFilter() ?? null; + } + + @computed + get isDirty(): boolean { + const {value, committedValue} = this; + if (value == null && committedValue == null) return false; + if (value == null || committedValue == null) return true; + return !value.equals(committedValue); + } + + @computed + get isEmpty(): boolean { + return this.rootGroup?.isEmpty ?? true; + } + + constructor({ + fieldSpecs, + fieldSpecDefaults, + bind = null, + valueSource, + initialValue = null, + initialFavorites = [], + commitOnChange = false, + maxGroupDepth = 3, + persistWith + }: FilterBuilderConfig = {}) { + super(); + makeObservable(this); + + this.bind = bind; + this.commitOnChange = commitOnChange; + this.maxGroupDepth = maxGroupDepth; + + this.valueSource = valueSource; + if (!this.valueSource && isFilterValueSource(bind)) { + this.valueSource = bind; + } + + this.fieldSpecs = this.parseFieldSpecs(fieldSpecs, fieldSpecDefaults); + + // Initialize working tree from initial value + const initFilter = parseFilter(executeIfFunction(initialValue)); + this.rootGroup = FilterGroupNode.fromFilter(initFilter); + this.committedValue = initFilter; + + // Initialize favorites + this.setFavorites(executeIfFunction(initialFavorites) ?? []); + + // Persistence + if (persistWith) this.initPersist(persistWith); + + // Initial outbound sync + if (bind && initFilter) this.syncToTarget(); + + // Inbound sync: track changes on the bind target + if (bind) { + this.addReaction({ + track: () => bind.filter, + run: filter => this.handleInboundFilterChange(filter) + }); + } + + // commitOnChange auto-apply + this.addReaction({ + track: () => this.value, + run: () => { + if (this.commitOnChange && !this._suppressSync) { + this.apply(); + } + } + }); + } + + //-------------------- + // Actions + //-------------------- + @action + addRule(parentGroup?: FilterGroupNode) { + const group = parentGroup ?? this.rootGroup; + return group.addRule(); + } + + @action + addGroup(parentGroup?: FilterGroupNode) { + const group = parentGroup ?? this.rootGroup; + return group.addGroup(); + } + + @action + removeNode(node: any, parentGroup?: FilterGroupNode) { + const parent = parentGroup ?? this.findParent(node); + parent?.removeChild(node); + } + + @action + moveNode(node: any, direction: 'up' | 'down', parentGroup?: FilterGroupNode) { + const parent = parentGroup ?? this.findParent(node); + parent?.moveChild(node, direction); + } + + @action + setGroupOp(group: FilterGroupNode, op: 'AND' | 'OR') { + group.setOp(op); + } + + @action + setGroupNot(group: FilterGroupNode, not: boolean) { + group.setNot(not); + } + + /** Commit the current working tree value to the bound target. */ + @action + apply() { + this.committedValue = this.value; + this.syncToTarget(); + } + + /** Revert the working tree to the last committed state. */ + @action + cancel() { + this._suppressSync = true; + this.rootGroup.destroy(); + this.rootGroup = FilterGroupNode.fromFilter(this.committedValue); + this._suppressSync = false; + } + + /** Clear the working tree entirely. */ + @action + clear() { + this.rootGroup.destroy(); + this.rootGroup = new FilterGroupNode(); + if (this.commitOnChange) this.apply(); + } + + /** Alias for cancel. */ + reset() { + this.cancel(); + } + + //-------------------- + // Favorites + //-------------------- + get favoritesOptions() { + return this.favorites.map(value => ({value})); + } + + @action + setFavorites(favorites: FilterLike[]) { + this.favorites = favorites.map(parseFilter).filter(f => f != null); + } + + @action + addFavorite(filter?: Filter) { + const toAdd = filter ?? this.value; + if (!toAdd || this.isFavorite(toAdd)) return; + this.favorites = [...this.favorites, toAdd]; + } + + @action + removeFavorite(filter: Filter) { + this.favorites = this.favorites.filter(f => !f.equals(filter)); + } + + isFavorite(filter: Filter): boolean { + return !!this.favorites?.find(f => f.equals(filter)); + } + + /** Load a favorite into the working tree. */ + @action + loadFavorite(filter: Filter) { + this._suppressSync = true; + this.rootGroup.destroy(); + this.rootGroup = FilterGroupNode.fromFilter(filter); + this._suppressSync = false; + if (this.commitOnChange) this.apply(); + } + + //-------------------- + // Field Spec Access + //-------------------- + getFieldSpec(fieldName: string): FilterBuilderFieldSpec { + return this.fieldSpecs.find(it => it.field === fieldName); + } + + /** Get the depth of a group within the working tree (root = 0). */ + getGroupDepth(group: FilterGroupNode): number { + return this.findDepth(group, this.rootGroup, 0); + } + + //---------------------------- + // Implementation + //---------------------------- + private parseFieldSpecs( + specs: Array, + fieldSpecDefaults: Partial + ): FilterBuilderFieldSpec[] { + const {valueSource} = this; + + throwIf( + !valueSource && (!specs || specs.some(isString)), + 'Must provide a valueSource if fieldSpecs are not provided, or provided as strings.' + ); + + if (!specs) specs = valueSource.fieldNames; + + return specs.map(spec => { + if (isString(spec)) spec = {field: spec}; + return new FilterBuilderFieldSpec({ + source: valueSource, + ...fieldSpecDefaults, + ...spec + }); + }); + } + + @action + private handleInboundFilterChange(filter: Filter) { + if (this._suppressSync) return; + + // Strip FunctionFilters — unsupported by FilterBuilder + const cleaned = filter?.removeFunctionFilters() ?? null; + + // If we have uncommitted edits, don't overwrite — log a warning + if (this.isDirty) { + this.logWarn( + 'Bound target filter changed while FilterBuilder has uncommitted edits.', + 'Preserving working tree.' + ); + return; + } + + this._suppressSync = true; + this.rootGroup.destroy(); + this.rootGroup = FilterGroupNode.fromFilter(cleaned); + this.committedValue = cleaned; + this._suppressSync = false; + } + + private syncToTarget() { + const {bind, value} = this; + if (!bind) return; + + this._suppressSync = true; + try { + const filter = appendFilter(bind.filter?.removeFieldFilters(), value); + bind.setFilter(filter); + } finally { + this._suppressSync = false; + } + } + + private findParent(node: any, group: FilterGroupNode = this.rootGroup): FilterGroupNode { + if (group.children.includes(node)) return group; + for (const child of group.children) { + if (child instanceof FilterGroupNode) { + const found = this.findParent(node, child); + if (found) return found; + } + } + return null; + } + + private findDepth(target: FilterGroupNode, current: FilterGroupNode, depth: number): number { + if (current === target) return depth; + for (const child of current.children) { + if (child instanceof FilterGroupNode) { + const found = this.findDepth(target, child, depth + 1); + if (found >= 0) return found; + } + } + return -1; + } + + private initPersist({ + persistValue = true, + persistFavorites = true, + path = 'filterBuilder', + ...rootPersistWith + }: FilterBuilderPersistOptions) { + if (persistValue) { + const status = {initialized: false}, + persistWith = isObject(persistValue) + ? PersistenceProvider.mergePersistOptions(rootPersistWith, persistValue) + : rootPersistWith; + PersistenceProvider.create({ + persistOptions: { + path: `${path}.value`, + ...persistWith + }, + target: { + getPersistableState: () => new PersistableState(this.value?.toJSON() ?? null), + setPersistableState: ({value}) => { + if (!status.initialized) { + const filter = parseFilter(value); + this.rootGroup = FilterGroupNode.fromFilter(filter); + this.committedValue = filter; + } + } + }, + owner: this + }); + status.initialized = true; + } + + if (persistFavorites) { + const persistWith = isObject(persistFavorites) + ? PersistenceProvider.mergePersistOptions(rootPersistWith, persistFavorites) + : rootPersistWith, + provider = PersistenceProvider.create({ + persistOptions: { + path: `${path}.favorites`, + ...persistWith + }, + target: { + getPersistableState: () => + new PersistableState(this.favorites.map(f => f.toJSON())), + setPersistableState: ({value}) => this.setFavorites(value) + }, + owner: this + }); + if (provider) this.persistFavorites = true; + } + } + + override destroy() { + this.rootGroup?.destroy(); + super.destroy(); + } +} + +interface FilterBuilderPersistOptions extends PersistOptions { + /** True (default) to include value or provide value-specific PersistOptions. */ + persistValue?: boolean | PersistOptions; + /** True (default) to include favorites or provide favorites-specific PersistOptions. */ + persistFavorites?: boolean | PersistOptions; +} diff --git a/cmp/filter/FilterChooserFieldSpec.ts b/cmp/filter/FilterChooserFieldSpec.ts index 2f8b4fd1b..98ab1ad8e 100644 --- a/cmp/filter/FilterChooserFieldSpec.ts +++ b/cmp/filter/FilterChooserFieldSpec.ts @@ -5,10 +5,7 @@ * Copyright © 2026 Extremely Heavy Industries Inc. */ import {parseFieldValue} from '@xh/hoist/data'; -import { - BaseFilterFieldSpec, - BaseFilterFieldSpecConfig -} from '@xh/hoist/data/filter/BaseFilterFieldSpec'; +import {FilterFieldSpec, FilterFieldSpecConfig} from '@xh/hoist/data/filter/FilterFieldSpec'; import {FieldFilterOperator} from '@xh/hoist/data/filter/Types'; import {fmtDate, parseNumber} from '@xh/hoist/format'; import {stripTags, throwIf} from '@xh/hoist/utils/js'; @@ -16,7 +13,7 @@ import {renderToStaticMarkup} from '@xh/hoist/utils/react'; import {isFunction} from 'lodash'; import {isValidElement, ReactNode} from 'react'; -export interface FilterChooserFieldSpecConfig extends BaseFilterFieldSpecConfig { +export interface FilterChooserFieldSpecConfig extends FilterFieldSpecConfig { /** * Function to produce a suitably formatted string for display to the user * for any given field value. @@ -40,7 +37,7 @@ export interface FilterChooserFieldSpecConfig extends BaseFilterFieldSpecConfig * Apps should NOT instantiate this class directly. Instead see {@link FilterChooserModel.fieldSpecs} * for the relevant config to set these options. */ -export class FilterChooserFieldSpec extends BaseFilterFieldSpec { +export class FilterChooserFieldSpec extends FilterFieldSpec { valueRenderer: FilterChooserValueRenderer; valueParser: FilterChooserValueParser; example: string; diff --git a/cmp/filter/README.md b/cmp/filter/README.md new file mode 100644 index 000000000..c0a0cca7f --- /dev/null +++ b/cmp/filter/README.md @@ -0,0 +1,202 @@ +# Filter Components + +## Overview + +The `/cmp/filter/` package provides cross-platform models for building filter UIs that bind to +Hoist's filter system. These models work with the immutable `Filter` classes in `/data/filter/` but +add mutable state management, persistence, favorites, and bi-directional binding to filter targets +(Stores, Cube Views). + +Two complementary approaches are provided: + +- **FilterChooser** — A compact, typeahead-style input for quick filter construction. Users type + field names and values into an OmniBox-style control that parses input into structured filters. + Best for experienced users who know their data fields and want fast, keyboard-driven filtering. + +- **FilterBuilder** — A visual query builder panel for constructing filters of arbitrary complexity. + Supports nested AND/OR groups with NOT negation, type-appropriate value editors, and an explicit + Apply/Cancel workflow. Best for complex filter construction and users who prefer a guided, + visual interface. + +Both components bind to the same `FilterBindTarget` interface and can be used together on the same +Store — changes in one are automatically reflected in the other via MobX reactions. + +## FilterChooserModel + +`FilterChooserModel` powers the FilterChooser component — a tokenized input field where users build +filters by typing field names, operators, and values. The model parses free-text input into +structured `FieldFilter` and `CompoundFilter` instances. + +### Basic Usage + +```typescript +import {FilterChooserModel} from '@xh/hoist/cmp/filter'; + +const filterChooserModel = new FilterChooserModel({ + bind: store, + fieldSpecs: [ + 'company', + 'city', + 'trade_date', + {field: 'profit_loss', valueRenderer: numberRenderer({precision: 0})}, + {field: 'trade_volume', valueRenderer: millionsRenderer({precision: 1, label: true})} + ] +}); +``` + +### Key Configuration + +| Property | Type | Description | +|----------|------|-------------| +| `bind` | `FilterBindTarget` | Store or Cube View to filter. Bi-directional binding. | +| `valueSource` | `FilterValueSource` | Source for field metadata and value suggestions. Defaults to `bind`. | +| `fieldSpecs` | `(string \| FilterChooserFieldSpecConfig)[]` | Fields available for filtering. Strings are resolved against `valueSource`. | +| `persistWith` | `PersistOptions` | Persist filter value and/or favorites to localStorage, Preferences, etc. | +| `initialValue` | `FilterLike` | Starting filter value. | +| `maxResults` | `number` | Max typeahead suggestions shown. Default 10. | +| `sortBy` | `string \| Function` | Sort order for value suggestions. | + +### Favorites + +FilterChooserModel supports saving and loading favorite filters — named filter configurations that +users can quickly recall. Enable via `persistWith` with `persistFavorites: true`. + +### Platform Components + +- **Desktop**: `filterChooser` in `@xh/hoist/desktop/cmp/filter` +- **Mobile**: `filterChooser` in `@xh/hoist/mobile/cmp/filter` + +## FilterBuilderModel + +`FilterBuilderModel` powers the FilterBuilder component — a visual query builder for constructing +filters with nested groups, multiple operators, and type-appropriate value editors. It maintains a +mutable working tree of filter nodes that can be committed to a bound target. + +### Basic Usage + +```typescript +import {FilterBuilderModel} from '@xh/hoist/cmp/filter'; + +const filterBuilderModel = new FilterBuilderModel({ + bind: store, + fieldSpecs: [ + 'active', + 'company', + 'city', + 'trade_date', + {field: 'profit_loss'}, + {field: 'trade_volume'} + ] +}); +``` + +### Key Configuration + +| Property | Type | Description | +|----------|------|-------------| +| `bind` | `FilterBindTarget` | Store or Cube View to filter. Bi-directional binding. | +| `valueSource` | `FilterValueSource` | Source for field metadata and value suggestions. Defaults to `bind`. | +| `fieldSpecs` | `(string \| FilterBuilderFieldSpecConfig)[]` | Fields available for filtering. Strings are resolved against `valueSource`. | +| `commitOnChange` | `boolean` | When `true`, edits sync immediately. When `false` (default), requires explicit `apply()`. | +| `maxGroupDepth` | `number` | Maximum nesting depth for filter groups. Default 3. | +| `persistWith` | `FilterBuilderPersistOptions` | Persist value and/or favorites. Supports split stores for each. | +| `initialValue` | `FilterLike` | Starting filter value. | +| `initialFavorites` | `FilterLike[]` | Starting favorites list. | + +### Working Tree Architecture + +FilterBuilderModel maintains a mutable tree of observable nodes representing the user's in-progress +filter. This tree is separate from the immutable `Filter` objects used by the rest of the system: + +- **FilterGroupNode** — Represents an AND/OR group with optional NOT negation. Contains child + rules and nested groups. +- **FilterRuleNode** — Represents a single field/operator/value filter condition. + +The `value` computed property converts the working tree to an immutable `Filter` on demand. +The `apply()` method commits this value to the bound target. + +### Apply/Cancel Workflow + +When `commitOnChange` is `false` (the default), the FilterBuilder shows Apply and Cancel buttons: + +- **Apply** commits the working tree to the bound target +- **Cancel** reverts the working tree to the last committed state +- **Clear** removes all rules and groups (auto-applies when `commitOnChange` is `true`) + +The `isDirty` computed tracks whether the working tree differs from the committed value. + +### Favorites + +FilterBuilderModel supports the same favorites pattern as FilterChooserModel. Enable via +`persistWith` with `persistFavorites: true`: + +```typescript +const filterBuilderModel = new FilterBuilderModel({ + bind: store, + fieldSpecs: ['company', 'city', 'trade_date'], + persistWith: { + localStorageKey: 'myAppFilterBuilder', + persistFavorites: true + } +}); +``` + +### Bi-directional Binding + +When both a FilterBuilder and FilterChooser are bound to the same Store, they stay in sync +automatically. Changes applied by one are reflected in the other via MobX reactions on the shared +`FilterBindTarget.filter` property. No special interop code is needed. + +Note: When `commitOnChange` is `false` and the FilterBuilder has uncommitted edits, inbound changes +from the bound target are ignored (with a console warning) to avoid overwriting the user's work. + +### Platform Components + +- **Desktop**: `filterBuilder` in `@xh/hoist/desktop/cmp/filter` +- **Mobile**: Not yet available (planned). + +## FilterBuilderFieldSpec + +Extends `FilterFieldSpec` with builder-specific configuration. Apps do not instantiate this class +directly — it is created internally by `FilterBuilderModel` from the `fieldSpecs` config. + +| Property | Type | Description | +|----------|------|-------------| +| `defaultOperator` | `FieldFilterOperator` | Pre-selected operator for new rules. Defaults to first available op. | + +Inherits all base `FilterFieldSpec` properties including `field`, `fieldType`, `ops`, +`enableValues`, and `values`. + +## Using FilterBuilder and FilterChooser Together + +A common pattern places a FilterBuilder panel alongside a Grid, with a FilterChooser in a toolbar +for quick access — all bound to the same Store: + +```typescript +class MyPanelModel extends HoistModel { + @managed gridModel = new GridModel({ + store: {fields: ['company', 'city', 'profit_loss']}, + columns: [companyCol, cityCol, profitLossCol], + colDefaults: {filterable: true} + }); + + @managed filterBuilderModel = new FilterBuilderModel({ + bind: this.gridModel.store, + fieldSpecs: ['company', 'city', {field: 'profit_loss'}] + }); + + @managed filterChooserModel = new FilterChooserModel({ + bind: this.gridModel.store, + fieldSpecs: ['company', 'city', {field: 'profit_loss'}] + }); +} +``` + +## Related + +- [`/data/filter/`](../../data/README.md) — Immutable Filter classes (FieldFilter, CompoundFilter, + FunctionFilter) and the FilterBindTarget interface +- [`/cmp/grid/`](../grid/README.md) — GridModel with built-in column-level filtering via + GridFilterModel +- [Persistence](../../docs/persistence.md) — How `persistWith` works across localStorage, + Preferences, and other stores diff --git a/cmp/filter/impl/FilterGroupNode.ts b/cmp/filter/impl/FilterGroupNode.ts new file mode 100644 index 000000000..6c0db7fa5 --- /dev/null +++ b/cmp/filter/impl/FilterGroupNode.ts @@ -0,0 +1,133 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import {HoistBase} from '@xh/hoist/core'; +import {CompoundFilter, FieldFilter, Filter} from '@xh/hoist/data'; +import {CompoundFilterOperator} from '@xh/hoist/data/filter/Types'; +import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {compact} from 'lodash'; +import {FilterRuleNode} from './FilterRuleNode'; + +export type FilterNode = FilterGroupNode | FilterRuleNode; + +/** + * Mutable observable node representing a compound filter group in the working tree. + * @internal + */ +export class FilterGroupNode extends HoistBase { + @observable op: CompoundFilterOperator = 'AND'; + @observable not: boolean = false; + @observable.ref children: FilterNode[] = []; + + constructor() { + super(); + makeObservable(this); + } + + @computed + get isEmpty(): boolean { + return this.children.length === 0; + } + + @computed + get isComplete(): boolean { + return ( + this.children.length > 0 && + this.children.every(child => + child instanceof FilterGroupNode ? child.isComplete : child.isComplete + ) + ); + } + + /** Convert to an immutable CompoundFilter, or null if empty. */ + toFilter(): CompoundFilter { + const filters = compact(this.children.map(child => child.toFilter())), + {op, not} = this; + if (filters.length === 0) return null; + return new CompoundFilter({filters, op, not}); + } + + @action + addRule(): FilterRuleNode { + const rule = new FilterRuleNode(); + this.children = [...this.children, rule]; + return rule; + } + + @action + addGroup(): FilterGroupNode { + const group = new FilterGroupNode(); + this.children = [...this.children, group]; + return group; + } + + @action + removeChild(child: FilterNode) { + this.children = this.children.filter(c => c !== child); + child.destroy(); + } + + @action + moveChild(child: FilterNode, direction: 'up' | 'down') { + const children = [...this.children], + idx = children.indexOf(child); + if (idx < 0) return; + + const targetIdx = direction === 'up' ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= children.length) return; + + children[idx] = children[targetIdx]; + children[targetIdx] = child; + this.children = children; + } + + @action + setOp(op: CompoundFilterOperator) { + this.op = op; + } + + @action + setNot(not: boolean) { + this.not = not; + } + + /** Convert an immutable Filter into a mutable working tree for editing. */ + static fromFilter(filter: Filter): FilterGroupNode { + const group = new FilterGroupNode(); + if (!filter) return group; + + if (CompoundFilter.isCompoundFilter(filter)) { + group.op = filter.op === 'AND' || filter.op === 'and' ? 'AND' : 'OR'; + group.not = filter.not; + group.children = filter.filters + .map(child => { + if (CompoundFilter.isCompoundFilter(child)) { + return FilterGroupNode.fromFilter(child); + } + if (FieldFilter.isFieldFilter(child)) { + return FilterRuleNode.fromFilter(child); + } + // Unsupported filter type — skip + return null; + }) + .filter(Boolean); + return group; + } + + if (FieldFilter.isFieldFilter(filter)) { + group.children = [FilterRuleNode.fromFilter(filter)]; + return group; + } + + // Unsupported filter type — return empty group + return group; + } + + override destroy() { + this.children.forEach(child => child.destroy()); + super.destroy(); + } +} diff --git a/cmp/filter/impl/FilterRuleNode.ts b/cmp/filter/impl/FilterRuleNode.ts new file mode 100644 index 000000000..dc2fd57ae --- /dev/null +++ b/cmp/filter/impl/FilterRuleNode.ts @@ -0,0 +1,68 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import {HoistBase} from '@xh/hoist/core'; +import {FieldFilter} from '@xh/hoist/data'; +import {FieldFilterOperator} from '@xh/hoist/data/filter/Types'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; + +/** + * Mutable observable node representing a single filter rule in the working tree. + * @internal + */ +export class FilterRuleNode extends HoistBase { + @observable field: string = null; + @observable op: FieldFilterOperator = null; + @observable value: any = null; + + constructor() { + super(); + makeObservable(this); + } + + get isComplete(): boolean { + const {field, op, value} = this; + return field != null && op != null && value != null; + } + + /** Convert to an immutable FieldFilter, or null if incomplete. */ + toFilter(): FieldFilter { + if (!this.isComplete) return null; + const {field, op, value} = this; + return new FieldFilter({field, op, value}); + } + + @action + setField(field: string) { + this.field = field; + } + + @action + setOp(op: FieldFilterOperator) { + this.op = op; + } + + @action + setValue(value: any) { + this.value = value; + } + + @action + clear() { + this.field = null; + this.op = null; + this.value = null; + } + + /** Create a FilterRuleNode from an existing FieldFilter. */ + static fromFilter(filter: FieldFilter): FilterRuleNode { + const node = new FilterRuleNode(); + node.field = filter.field; + node.op = filter.op; + node.value = filter.value; + return node; + } +} diff --git a/cmp/filter/index.ts b/cmp/filter/index.ts index bdc55f936..5ed163a18 100644 --- a/cmp/filter/index.ts +++ b/cmp/filter/index.ts @@ -1 +1,3 @@ +export * from './FilterBuilderFieldSpec'; +export * from './FilterBuilderModel'; export * from './FilterChooserModel'; diff --git a/cmp/grid/filter/GridFilterFieldSpec.ts b/cmp/grid/filter/GridFilterFieldSpec.ts index 038773285..1aab42fdd 100644 --- a/cmp/grid/filter/GridFilterFieldSpec.ts +++ b/cmp/grid/filter/GridFilterFieldSpec.ts @@ -14,14 +14,11 @@ import { Filter, parseFilter } from '@xh/hoist/data'; -import { - BaseFilterFieldSpec, - BaseFilterFieldSpecConfig -} from '@xh/hoist/data/filter/BaseFilterFieldSpec'; +import {FilterFieldSpec, FilterFieldSpecConfig} from '@xh/hoist/data/filter/FilterFieldSpec'; import {castArray, compact, flatMap, isDate, isEmpty, uniqBy} from 'lodash'; import {GridFilterModel} from './GridFilterModel'; -export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig { +export interface GridFilterFieldSpecConfig extends FilterFieldSpecConfig { /** GridFilterModel instance owning this fieldSpec. */ filterModel?: GridFilterModel; @@ -45,7 +42,7 @@ export interface GridFilterFieldSpecConfig extends BaseFilterFieldSpecConfig { * Apps should NOT instantiate this class directly. * Instead, provide a config for this object via {@link GridConfig.filterModel} config. */ -export class GridFilterFieldSpec extends BaseFilterFieldSpec { +export class GridFilterFieldSpec extends FilterFieldSpec { filterModel: GridFilterModel; renderer: ColumnRenderer; inputProps: PlainObject; diff --git a/data/README.md b/data/README.md index 927232ef7..6fcb79b7a 100644 --- a/data/README.md +++ b/data/README.md @@ -961,5 +961,5 @@ class MyModel extends HoistModel { - [`/core/`](../core/README.md) - HoistModel, HoistBase - base classes Store extends - [`/cmp/grid/`](../cmp/grid/README.md) - GridModel consumes Store for data display - [`/cmp/form/`](../cmp/form/README.md) - FormModel uses similar Field and validation patterns -- `/cmp/filter/` - UI components for filter construction +- [`/cmp/filter/`](../cmp/filter/README.md) - FilterChooser and FilterBuilder UI components for filter construction - `/cmp/grouping/` - GroupingChooser for specifying multi-level dimension groupings diff --git a/data/filter/CompoundFilter.ts b/data/filter/CompoundFilter.ts index 4eebcee53..7b0a1c314 100644 --- a/data/filter/CompoundFilter.ts +++ b/data/filter/CompoundFilter.ts @@ -23,6 +23,7 @@ export class CompoundFilter extends Filter { readonly filters: Filter[]; readonly op: CompoundFilterOperator; + readonly not: boolean; /** @returns the singular field this filter operates on, if consistent across all clauses. */ get field(): string { @@ -36,13 +37,14 @@ export class CompoundFilter extends Filter { * Constructor - not typically called by apps - create via {@link parseFilter} instead. * @internal */ - constructor({filters, op = 'AND'}: CompoundFilterSpec) { + constructor({filters, op = 'AND', not}: CompoundFilterSpec) { super(); op = (op as any)?.toUpperCase(); throwIf(op !== 'AND' && op !== 'OR', 'CompoundFilter requires "op" value of "AND" or "OR"'); this.filters = compact(filters.map(parseFilter)); this.op = op; + this.not = !!not; Object.freeze(this); } @@ -50,11 +52,13 @@ export class CompoundFilter extends Filter { // Overrides //----------------- override getTestFn(store?: Store): FilterTestFn { - const {op, filters} = this; + const {op, not, filters} = this; if (isEmpty(filters)) return () => true; - const tests = filters.map(f => f.getTestFn(store)); - return op === 'AND' ? r => tests.every(test => test(r)) : r => tests.some(test => test(r)); + const tests = filters.map(f => f.getTestFn(store)), + baseFn: FilterTestFn = + op === 'AND' ? r => tests.every(test => test(r)) : r => tests.some(test => test(r)); + return not ? r => !baseFn(r) : baseFn; } override equals(other: Filter): boolean { @@ -62,6 +66,7 @@ export class CompoundFilter extends Filter { return ( other instanceof CompoundFilter && other.op === this.op && + other.not === this.not && isEqualWith(other.filters, this.filters, (a, b) => Filter.isFilter(a) && Filter.isFilter(b) ? a.equals(b) : undefined ) @@ -71,7 +76,8 @@ export class CompoundFilter extends Filter { override toJSON(): CompoundFilterSpec { return { filters: this.filters.map(f => f.toJSON()), - op: this.op + op: this.op, + ...(this.not ? {not: true} : {}) }; } @@ -91,7 +97,7 @@ export class CompoundFilter extends Filter { * Returns same instance if nothing changed, single child if one remains, or null if empty. */ private applyRemove(removeFn: (f: Filter) => Filter | null): Filter { - const {filters, op} = this, + const {filters, op, not} = this, result = compact(filters.map(removeFn)); if (result.length === filters.length && result.every((f, i) => f === filters[i])) { @@ -99,6 +105,6 @@ export class CompoundFilter extends Filter { } if (result.length === 0) return null; if (result.length === 1) return result[0]; - return new CompoundFilter({filters: result, op}); + return new CompoundFilter({filters: result, op, not}); } } diff --git a/data/filter/BaseFilterFieldSpec.ts b/data/filter/FilterFieldSpec.ts similarity index 97% rename from data/filter/BaseFilterFieldSpec.ts rename to data/filter/FilterFieldSpec.ts index 0a5ea12bc..8b004795b 100644 --- a/data/filter/BaseFilterFieldSpec.ts +++ b/data/filter/FilterFieldSpec.ts @@ -9,7 +9,7 @@ import {Field, FieldFilter, FieldType, FilterValueSource, genDisplayName} from ' import {compact, isArray, isEmpty} from 'lodash'; import {FieldFilterOperator} from './Types'; -export interface BaseFilterFieldSpecConfig { +export interface FilterFieldSpecConfig { /** Identifying field name to filter on. */ field: string; /** Type of field, will default from related field on source if provided, or 'auto'. */ @@ -42,7 +42,7 @@ export interface BaseFilterFieldSpecConfig { * @see FilterChooserFieldSpec * @see GridFilterFieldSpec */ -export abstract class BaseFilterFieldSpec extends HoistBase { +export abstract class FilterFieldSpec extends HoistBase { field: string; fieldType: FieldType; displayName: string; @@ -62,7 +62,7 @@ export abstract class BaseFilterFieldSpec extends HoistBase { enableValues, forceSelection, values - }: BaseFilterFieldSpecConfig) { + }: FilterFieldSpecConfig) { super(); this.field = field; this.source = source; diff --git a/data/filter/Types.ts b/data/filter/Types.ts index 920e1e306..34f035e7a 100644 --- a/data/filter/Types.ts +++ b/data/filter/Types.ts @@ -48,6 +48,9 @@ export interface CompoundFilterSpec { /** logical operator 'AND' (default) or 'OR'. */ op?: CompoundFilterOperator; + + /** True to negate the result of this compound filter. Default false. */ + not?: boolean; } export type CompoundFilterOperator = 'AND' | 'OR' | 'and' | 'or'; diff --git a/desktop/cmp/filter/FilterBuilder.scss b/desktop/cmp/filter/FilterBuilder.scss new file mode 100644 index 000000000..bc583da03 --- /dev/null +++ b/desktop/cmp/filter/FilterBuilder.scss @@ -0,0 +1,61 @@ +.xh-filter-builder { + display: flex; + flex-direction: column; + + .xh-filter-builder__content { + flex: 1; + overflow: auto; + padding: var(--xh-pad-half-px); + } + + .xh-filter-builder__group { + margin-bottom: var(--xh-pad-half-px); + padding: var(--xh-pad-half-px); + border: var(--xh-border-solid); + border-radius: var(--xh-border-radius-px); + background: var(--xh-bg-alt); + overflow: hidden; + + &--depth-1 { + margin-left: var(--xh-pad-px); + } + &--depth-2 { + margin-left: calc(var(--xh-pad-px) * 2); + } + &--depth-3 { + margin-left: calc(var(--xh-pad-px) * 3); + } + + &--negated { + border-left: 3px solid var(--xh-red); + } + } + + .xh-filter-builder__group-header { + gap: var(--xh-pad-half-px); + + .xh-button-group-input .xh-button, + > .xh-button.xh-button--outlined { + min-width: 42px; + font-size: var(--xh-font-size-small-px); + } + } + + .xh-filter-builder__rule { + padding: var(--xh-pad-half-px) 0; + gap: 2px; + } + + .xh-filter-builder__rule-inputs { + gap: var(--xh-pad-half-px); + overflow: hidden; + + > * { + min-width: 0; + } + } + + .xh-filter-builder__bbar { + border-top: var(--xh-border-solid); + } +} diff --git a/desktop/cmp/filter/FilterBuilder.ts b/desktop/cmp/filter/FilterBuilder.ts new file mode 100644 index 000000000..439a895b7 --- /dev/null +++ b/desktop/cmp/filter/FilterBuilder.ts @@ -0,0 +1,402 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2026 Extremely Heavy Industries Inc. + */ +import {FilterBuilderFieldSpec, FilterBuilderModel} from '@xh/hoist/cmp/filter'; +import {FilterGroupNode, FilterNode} from '@xh/hoist/cmp/filter/impl/FilterGroupNode'; +import {FilterRuleNode} from '@xh/hoist/cmp/filter/impl/FilterRuleNode'; +import {box, div, filler, hbox, vbox} from '@xh/hoist/cmp/layout'; +import {placeholder} from '@xh/hoist/cmp/layout/Placeholder'; +import {hoistCmp, HoistProps, LayoutProps, TestSupportProps, uses} from '@xh/hoist/core'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import { + buttonGroupInput, + dateInput, + numberInput, + select, + switchInput, + textInput +} from '@xh/hoist/desktop/cmp/input'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import '@xh/hoist/desktop/register'; +import {Icon} from '@xh/hoist/icon'; +import {menu, menuDivider, menuItem, popover} from '@xh/hoist/kit/blueprint'; +import {getTestId} from '@xh/hoist/utils/js'; +import {splitLayoutProps} from '@xh/hoist/utils/react'; +import classNames from 'classnames'; +import {isEmpty} from 'lodash'; +import './FilterBuilder.scss'; + +export interface FilterBuilderProps + extends HoistProps, LayoutProps, TestSupportProps {} + +/** + * A panel-based component for constructing filters of arbitrary complexity. + * Provides a visual query builder UI supporting nested AND/OR groups with NOT negation, + * type-appropriate value editors, and full integration with Hoist's filter binding system. + * + * @see FilterBuilderModel + */ +export const [FilterBuilder, filterBuilder] = hoistCmp.withFactory({ + displayName: 'FilterBuilder', + model: uses(FilterBuilderModel), + className: 'xh-filter-builder', + + render({model, className, testId, ...props}, ref) { + const [layoutProps] = splitLayoutProps(props), + {rootGroup, isEmpty: modelIsEmpty} = model; + + return vbox({ + ref, + className, + testId, + ...layoutProps, + items: [ + box({ + flex: 1, + overflow: 'auto', + className: 'xh-filter-builder__content', + item: modelIsEmpty + ? placeholder({ + items: [ + Icon.filter(), + 'No filter rules defined.', + button({ + icon: Icon.add(), + text: 'Add Rule', + outlined: true, + intent: 'primary', + marginTop: 10, + testId: getTestId(testId, 'add-rule'), + onClick: () => model.addRule() + }) + ] + }) + : filterGroupCard({group: rootGroup, depth: 0, isRoot: true}) + }), + filterBuilderBbar() + ] + }); + } +}); + +//---------------------------------- +// Group Card (recursive) +//---------------------------------- +const filterGroupCard = hoistCmp.factory({ + render({model, group, depth, isRoot}: any) { + const canNest = depth < model.maxGroupDepth, + children = (group as FilterGroupNode).children; + + return vbox({ + className: classNames( + 'xh-filter-builder__group', + `xh-filter-builder__group--depth-${Math.min(depth, 3)}`, + {'xh-filter-builder__group--negated': group.not} + ), + items: [ + groupHeader({group, depth, isRoot, canNest}), + ...children.map((child: FilterNode) => { + if (child instanceof FilterGroupNode) { + return filterGroupCard({ + key: (child as any).xhId, + group: child, + depth: depth + 1, + isRoot: false + }); + } + return filterRuleRow({ + key: (child as any).xhId, + rule: child, + parentGroup: group + }); + }) + ] + }); + } +}); + +//---------------------------------- +// Group Header +//---------------------------------- +const groupHeader = hoistCmp.factory({ + render({model, group, isRoot, canNest}: any) { + return hbox({ + className: 'xh-filter-builder__group-header', + alignItems: 'center', + items: [ + buttonGroupInput({ + value: group.op, + onChange: op => model.setGroupOp(group, op), + outlined: true, + items: [button({text: 'AND', value: 'AND'}), button({text: 'OR', value: 'OR'})] + }), + button({ + text: 'NOT', + outlined: true, + active: group.not, + intent: group.not ? 'danger' : null, + onClick: () => model.setGroupNot(group, !group.not) + }), + filler(), + button({ + icon: Icon.add(), + text: 'Rule', + minimal: true, + onClick: () => model.addRule(group) + }), + button({ + icon: Icon.add(), + text: 'Group', + minimal: true, + omit: !canNest, + onClick: () => model.addGroup(group) + }), + button({ + icon: Icon.delete(), + minimal: true, + intent: 'danger', + omit: isRoot, + onClick: () => model.removeNode(group) + }) + ] + }); + } +}); + +//---------------------------------- +// Rule Row +//---------------------------------- +const filterRuleRow = hoistCmp.factory({ + render({model, rule, parentGroup}: any) { + const ruleNode = rule as FilterRuleNode, + fieldSpec = ruleNode.field ? model.getFieldSpec(ruleNode.field) : null, + fieldOptions = model.fieldSpecs.map(spec => ({ + label: spec.displayName, + value: spec.field + })), + opOptions = fieldSpec ? fieldSpec.ops.map(op => ({label: op, value: op})) : []; + + return hbox({ + className: 'xh-filter-builder__rule', + alignItems: 'center', + items: [ + hbox({ + flex: 1, + className: 'xh-filter-builder__rule-inputs', + alignItems: 'center', + items: [ + select({ + className: 'xh-filter-builder__rule-field', + value: ruleNode.field, + options: fieldOptions, + enableFilter: true, + placeholder: 'Field...', + flex: 3, + onChange: field => { + ruleNode.setField(field); + const newSpec = field ? model.getFieldSpec(field) : null; + ruleNode.setOp(newSpec?.defaultOperator ?? null); + ruleNode.setValue(null); + } + }), + select({ + className: 'xh-filter-builder__rule-op', + value: ruleNode.op, + options: opOptions, + disabled: !fieldSpec, + placeholder: 'Op...', + width: 60, + hideDropdownIndicator: true, + onChange: op => ruleNode.setOp(op) + }), + renderValueEditor(ruleNode, fieldSpec) + ] + }), + button({ + icon: Icon.delete(), + minimal: true, + intent: 'danger', + onClick: () => model.removeNode(ruleNode, parentGroup) + }) + ] + }); + } +}); + +//---------------------------------- +// Value Editor (type-mapped) +//---------------------------------- +function renderValueEditor(rule: FilterRuleNode, fieldSpec: FilterBuilderFieldSpec) { + const className = 'xh-filter-builder__rule-value', + commonProps = { + className, + flex: 3, + value: rule.value, + disabled: !fieldSpec || !rule.op, + placeholder: 'Value...', + onChange: (v: any) => rule.setValue(v) + }; + + if (!fieldSpec) { + return textInput({...commonProps}); + } + + // Bool fields + if (fieldSpec.isBoolFieldType) { + return switchInput({ + className, + value: rule.value ?? false, + onChange: (v: any) => rule.setValue(v) + }); + } + + // Enumerable fields with suggestions for = / != + if (fieldSpec.supportsSuggestions(rule.op)) { + const isMulti = fieldSpec.isCollectionType; + return select({ + ...commonProps, + options: fieldSpec.values ?? [], + enableFilter: true, + enableMulti: isMulti, + enableClear: true + }); + } + + // Date-based fields + if (fieldSpec.isDateBasedFieldType) { + return dateInput({ + ...commonProps, + valueType: fieldSpec.fieldType === 'localDate' ? 'localDate' : 'date' + }); + } + + // Numeric fields + if (fieldSpec.isNumericFieldType) { + return numberInput({...commonProps}); + } + + // Tags + if (fieldSpec.isCollectionType) { + return select({ + ...commonProps, + options: fieldSpec.values ?? [], + enableMulti: true, + enableFilter: true, + enableClear: true + }); + } + + // Default: text input + return textInput({...commonProps}); +} + +//---------------------------------- +// Bottom Bar +//---------------------------------- +const filterBuilderBbar = hoistCmp.factory({ + render({model}) { + const {commitOnChange, isDirty, isEmpty: modelIsEmpty, persistFavorites} = model; + + return toolbar({ + className: 'xh-filter-builder__bbar', + items: [ + button({ + text: 'Clear', + disabled: modelIsEmpty, + onClick: () => model.clear() + }), + filler(), + favoritesButton({omit: !persistFavorites}), + button({ + text: 'Cancel', + omit: commitOnChange, + disabled: !isDirty, + onClick: () => model.cancel() + }), + button({ + text: 'Apply', + icon: Icon.check(), + intent: 'success', + outlined: true, + disabled: !isDirty, + omit: commitOnChange, + onClick: () => model.apply() + }) + ] + }); + } +}); + +//---------------------------------- +// Favorites +//---------------------------------- +const favoritesButton = hoistCmp.factory({ + render({model}) { + return popover({ + item: button({ + icon: Icon.favorite(), + text: 'Favorites' + }), + content: favoritesMenu(), + position: 'top-right' + }); + } +}); + +const favoritesMenu = hoistCmp.factory({ + render({model}) { + const {favorites, value} = model, + isFav = value ? model.isFavorite(value) : false, + items = []; + + if (isEmpty(favorites)) { + items.push(menuItem({text: 'No favorites saved...', disabled: true})); + } else { + favorites.forEach((fav, idx) => { + items.push( + menuItem({ + key: idx, + text: describeFavorite(fav), + onClick: () => model.loadFavorite(fav), + labelElement: button({ + icon: Icon.delete(), + minimal: true, + onClick: e => { + model.removeFavorite(fav); + e.stopPropagation(); + } + }) + }) + ); + }); + } + + items.push( + menuDivider({omit: !value || isFav}), + menuItem({ + icon: Icon.add({intent: 'success'}), + text: 'Save current filter', + omit: !value || isFav, + onClick: () => model.addFavorite() + }) + ); + + return vbox(div({className: 'xh-popup__title', item: 'Favorites'}), menu({items})); + } +}); + +function describeFavorite(filter: any): string { + if (!filter) return 'Empty'; + const json = filter.toJSON(); + if (json.field) return `${json.field} ${json.op} ${json.value}`; + if (json.filters) { + const count = json.filters.length, + op = json.op || 'AND', + prefix = json.not ? 'NOT ' : ''; + return `${prefix}${op} (${count} filter${count !== 1 ? 's' : ''})`; + } + return 'Filter'; +} diff --git a/desktop/cmp/filter/index.ts b/desktop/cmp/filter/index.ts index a862072cd..179df747d 100644 --- a/desktop/cmp/filter/index.ts +++ b/desktop/cmp/filter/index.ts @@ -1,3 +1,4 @@ +export * from './FilterBuilder'; export * from './FilterChooser'; export * from './PopoverFilterChooser'; export * from '@xh/hoist/cmp/filter'; diff --git a/docs/README.md b/docs/README.md index a0ec970ae..dab96ff9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ See [`docs-roadmap.md`](./planning/docs-roadmap.md) for documentation coverage t | Build a mobile app | [`/mobile/`](../mobile/README.md) | | Save and restore named view configurations | [`/cmp/viewmanager/`](../cmp/viewmanager/README.md) | | Use layout containers (Box, HBox, VBox, Frame) | [`/cmp/layout/`](../cmp/layout/README.md) | +| Build a visual query builder or filter chooser | [`/cmp/filter/`](../cmp/filter/README.md) | | Work with Stores, Records, Fields, or Filters | [`/data/`](../data/README.md) | | Use FetchService, ConfigService, or PrefService | [`/svc/`](../svc/README.md) | | Format numbers, dates, or currencies | [`/format/`](../format/README.md) | @@ -77,6 +78,7 @@ See [`docs-roadmap.md`](./planning/docs-roadmap.md) for documentation coverage t | Package | Description | Key Topics | |---------|-------------|------------| | [`/cmp/`](../cmp/README.md) | Cross-platform component overview and catalog | Component categories, factory pattern, platform-specific vs shared | +| [`/cmp/filter/`](../cmp/filter/README.md) | Filter UI components — visual query builder and compact typeahead chooser | FilterBuilderModel, FilterChooserModel, FilterBuilderFieldSpec, fieldSpecs, bind, favorites, AND/OR/NOT groups | | [`/cmp/grid/`](../cmp/grid/README.md) | Primary data grid built on ag-Grid | GridModel, Column, ColumnGroup, sorting, grouping, filtering, selection, inline editing, export | | [`/cmp/form/`](../cmp/form/README.md) | Form infrastructure for data entry with validation | FormModel, FieldModel, SubformsFieldModel, validation rules, data binding | | [`/cmp/input/`](../cmp/input/README.md) | Base classes and interfaces for input components | HoistInputModel, change/commit lifecycle, value binding, focus management | diff --git a/docs/doc-registry.json b/docs/doc-registry.json index 7f65dbedd..498ac79ff 100644 --- a/docs/doc-registry.json +++ b/docs/doc-registry.json @@ -67,6 +67,14 @@ "description": "Cross-platform component overview and catalog.", "keywords": ["component categories", "factory pattern", "DataView", "DataViewModel", "Treemap", "ZoneGrid", "Badge", "Spinner", "LoadingIndicator", "RelativeTimestamp", "Markdown"] }, + { + "id": "cmp/filter/README.md", + "title": "Filter Components", + "mcpCategory": "package", + "viewerCategory": "components", + "description": "Cross-platform models for filter construction UIs — FilterChooser (typeahead) and FilterBuilder (visual query builder).", + "keywords": ["FilterBuilderModel", "FilterChooserModel", "FilterBuilderFieldSpec", "FilterChooserFieldSpec", "fieldSpecs", "bind", "FilterBindTarget", "favorites", "commitOnChange", "AND", "OR", "NOT", "query builder", "filter groups"] + }, { "id": "cmp/grid/README.md", "title": "Grid Component", diff --git a/docs/planning/docs-roadmap-log.md b/docs/planning/docs-roadmap-log.md index 4b846c7e3..28e20ae88 100644 --- a/docs/planning/docs-roadmap-log.md +++ b/docs/planning/docs-roadmap-log.md @@ -503,3 +503,23 @@ - Added Quick Reference entry for "Customize colors, fonts, spacing, or theme" - Removed `/styles/` from "Other Packages" paragraph (now has a dedicated README) - Added `styles` entry to `mcp/data/doc-registry.ts` with CSS/theme/BEM keywords + +### 2026-03-10 +- Created `/cmp/filter/README.md` (Done) as part of the FilterBuilder feature work: + - Overview of the two complementary filter UI approaches (FilterChooser vs FilterBuilder) + - FilterChooserModel section with basic usage, key configuration table, favorites, platform components + - FilterBuilderModel section with basic usage, key configuration, working tree architecture, apply/cancel workflow, favorites, bi-directional binding + - FilterBuilderFieldSpec reference + - Combined usage example showing FilterBuilder + FilterChooser on the same Store + - Cross-references to data/README.md filter system, cmp/grid for GridFilterModel, persistence docs +- Updated `docs/README.md`: + - Added cmp/filter entry to Components table + - Added Quick Reference entry for "Build a visual query builder or filter chooser" +- Updated `docs/planning/docs-roadmap.md`: + - Added `/cmp/filter/` to Priority 2 table as Done +- Updated `docs/doc-registry.json`: + - Added cmp/filter/README.md entry with component category and filter-specific keywords +- Updated `data/README.md`: + - Linked `/cmp/filter/` reference in Related Packages section to the new README +- Updated `cmp/README.md`: + - Enhanced `/filter/` catalog entry description and linked to new README diff --git a/docs/planning/docs-roadmap.md b/docs/planning/docs-roadmap.md index fd731cfd2..6c352a449 100644 --- a/docs/planning/docs-roadmap.md +++ b/docs/planning/docs-roadmap.md @@ -29,6 +29,7 @@ interacts with Hoist. | `/desktop/cmp/panel/` | 7 | Panel container — toolbars, masks, collapse/resize, persistence, modal support | [Done](../../desktop/cmp/panel/README.md) | | `/desktop/cmp/dash/` | 14 | Dashboard system — DashContainer (GoldenLayout) and DashCanvas (react-grid-layout), widget persistence, ViewManager integration | [Done](../../desktop/cmp/dash/README.md) | | `/mobile/` | 131 | Mobile-specific components and app container | [Done](../../mobile/README.md) | +| `/cmp/filter/` | 9 | FilterChooser and FilterBuilder — cross-platform models for filter construction UIs | [Done](../../cmp/filter/README.md) | ## Priority 3 - Key Utilities From a4c451a98db3be643585b980ebce87c9e5a62251 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Thu, 12 Mar 2026 15:54:55 -0700 Subject: [PATCH 3/4] Clean up FilterBuilder review items - Simplify `FilterGroupNode.isComplete` (redundant ternary) - Simplify `fromFilter` op normalization - Add `@computed` to `FilterRuleNode.isComplete` for proper MobX caching - Use field spec display names in favorite descriptions, with safe fallback for stale favorites Co-Authored-By: Claude Opus 4.6 --- cmp/filter/impl/FilterGroupNode.ts | 9 ++------- cmp/filter/impl/FilterRuleNode.ts | 3 ++- desktop/cmp/filter/FilterBuilder.ts | 10 +++++++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmp/filter/impl/FilterGroupNode.ts b/cmp/filter/impl/FilterGroupNode.ts index 6c0db7fa5..3db767f40 100644 --- a/cmp/filter/impl/FilterGroupNode.ts +++ b/cmp/filter/impl/FilterGroupNode.ts @@ -34,12 +34,7 @@ export class FilterGroupNode extends HoistBase { @computed get isComplete(): boolean { - return ( - this.children.length > 0 && - this.children.every(child => - child instanceof FilterGroupNode ? child.isComplete : child.isComplete - ) - ); + return this.children.length > 0 && this.children.every(child => child.isComplete); } /** Convert to an immutable CompoundFilter, or null if empty. */ @@ -100,7 +95,7 @@ export class FilterGroupNode extends HoistBase { if (!filter) return group; if (CompoundFilter.isCompoundFilter(filter)) { - group.op = filter.op === 'AND' || filter.op === 'and' ? 'AND' : 'OR'; + group.op = filter.op?.toUpperCase() === 'AND' ? 'AND' : 'OR'; group.not = filter.not; group.children = filter.filters .map(child => { diff --git a/cmp/filter/impl/FilterRuleNode.ts b/cmp/filter/impl/FilterRuleNode.ts index dc2fd57ae..ff087d358 100644 --- a/cmp/filter/impl/FilterRuleNode.ts +++ b/cmp/filter/impl/FilterRuleNode.ts @@ -7,7 +7,7 @@ import {HoistBase} from '@xh/hoist/core'; import {FieldFilter} from '@xh/hoist/data'; import {FieldFilterOperator} from '@xh/hoist/data/filter/Types'; -import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; /** * Mutable observable node representing a single filter rule in the working tree. @@ -23,6 +23,7 @@ export class FilterRuleNode extends HoistBase { makeObservable(this); } + @computed get isComplete(): boolean { const {field, op, value} = this; return field != null && op != null && value != null; diff --git a/desktop/cmp/filter/FilterBuilder.ts b/desktop/cmp/filter/FilterBuilder.ts index 439a895b7..cef460401 100644 --- a/desktop/cmp/filter/FilterBuilder.ts +++ b/desktop/cmp/filter/FilterBuilder.ts @@ -359,7 +359,7 @@ const favoritesMenu = hoistCmp.factory({ items.push( menuItem({ key: idx, - text: describeFavorite(fav), + text: describeFavorite(fav, model), onClick: () => model.loadFavorite(fav), labelElement: button({ icon: Icon.delete(), @@ -388,10 +388,14 @@ const favoritesMenu = hoistCmp.factory({ } }); -function describeFavorite(filter: any): string { +function describeFavorite(filter: any, model: FilterBuilderModel): string { if (!filter) return 'Empty'; const json = filter.toJSON(); - if (json.field) return `${json.field} ${json.op} ${json.value}`; + if (json.field) { + const spec = model.getFieldSpec(json.field), + displayName = spec?.displayName ?? json.field; + return `${displayName} ${json.op} ${json.value}`; + } if (json.filters) { const count = json.filters.length, op = json.op || 'AND', From dfb7ceae1a5ab83336cbfea1c26e35aa74c7b8d7 Mon Sep 17 00:00:00 2001 From: Anselm McClain Date: Wed, 10 Jun 2026 08:48:08 -0700 Subject: [PATCH 4/4] Consolidate duplicate New Features sections in 86.0.0-SNAPSHOT changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 86.0.0-SNAPSHOT entry had two separate `### 🎁 New Features` sections; merged the persistence (`pathPrefix` / `persistOptions()`) bullets into the single New Features section ahead of Bug Fixes, per the standard changelog section order. --- CHANGELOG.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 036c6e585..80dfe2d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,14 @@ defer parsing and value commit until blur, Enter, or picker selection. Useful when configuring `parseStrings` such that one format is a prefix of another (e.g. `MM/DD/YY` and `MM/DD/YYYY`), where the eager default would reformat the user's text mid-typing. +* Added `pathPrefix` to `PersistOptions` - an inheritable prefix prepended to the resolved `path`, + concatenated through `persistOptions()`. Enables hierarchical namespacing of persistence so a + parent model can scope all descendants (`@persist` properties, `markPersist` calls, child + `GridModel` / `PanelModel` / etc.) under a single shared key in one backing store. See + [`docs/persistence.md`](docs/persistence.md#hierarchical-namespacing-with-pathprefix). +* Added exported `persistOptions()` function for merging one or more `PersistOptions` objects, + with later arguments overriding earlier ones. Replaces the now-deprecated + `PersistenceProvider.mergePersistOptions`. ### 🐞 Bug Fixes @@ -81,17 +89,6 @@ reconverge sequence of child updates; the aggregator now falls back to a sibling re-scan when the cache could be transitioning. -### 🎁 New Features - -* Added `pathPrefix` to `PersistOptions` - an inheritable prefix prepended to the resolved `path`, - concatenated through `persistOptions()`. Enables hierarchical namespacing of persistence so a - parent model can scope all descendants (`@persist` properties, `markPersist` calls, child - `GridModel` / `PanelModel` / etc.) under a single shared key in one backing store. See - [`docs/persistence.md`](docs/persistence.md#hierarchical-namespacing-with-pathprefix). -* Added exported `persistOptions()` function for merging one or more `PersistOptions` objects, - with later arguments overriding earlier ones. Replaces the now-deprecated - `PersistenceProvider.mergePersistOptions`. - ### 🤖 AI Docs + Tooling * Added a `hoist-read-doc` MCP tool that reads a full document by exact ID, giving MCP parity with