Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@

### 🎁 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`.
* Added `CheckboxButton` desktop input component — a button-based boolean toggle matching the
existing mobile component. Added `checkedIcon` and `uncheckedIcon` props to both desktop and
mobile versions for custom icon support.
* Added `SegmentedControl` desktop input component — a toggle group for mutually exclusive options
with strong visual differentiation of the active selection, an improvement over `ButtonGroupInput`
for small option sets.
* `FileChooser` gained extensive new capabilities as part of its redesign: a `maxFiles` limit,
fully customizable `emptyDisplay` / `fileDisplay` content, `onFileAccepted` / `onFileRejected`
callbacks, configurable rejection toasts, `maskOnDrag` / `maskOnDisabled` options, and a
Expand Down
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,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
Expand Down Expand Up @@ -229,6 +236,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,
Expand Down
2 changes: 1 addition & 1 deletion cmp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
44 changes: 44 additions & 0 deletions cmp/filter/FilterBuilderFieldSpec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading