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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### New Features

* Added Grids > FilterBuilder example page — demos the new `FilterBuilder` component with a companion `FilterChooser` bound to the same store for bi-directional sync. Includes toggleable `commitOnChange` and `favorites` options in the bottom toolbar.
* Added hoist-core documentation to the Docs tab alongside existing hoist-react docs. The viewer now shows both frameworks in a two-level tree (source > category > doc) with source badges in search results.
* Moved Docs content to a server-side API (`DocsService`) that dynamically resolves content from either a local sibling repo checkout or a GitHub tarball, replacing the previous webpack static asset approach.

Expand Down
20 changes: 19 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ XH.getPref('pageSize', 50); // PrefService alias
navigation (`navigate`, `appendRoute`), and app state (`appState`, `darkTheme`).

**Critical pitfalls:**
1. **Forgetting `makeObservable(this)`** — observables silently won't react.
1. **Forgetting `makeObservable(this)`** — observables silently won't react. This produces a
runtime console warning: *"Observable properties not initialized properly."* Always check the
browser console for warnings after creating or modifying model classes — this error is easy to
miss visually but breaks all reactivity for the affected class.
2. **Managing objects you don't own** — only `@managed` objects your class creates. Objects passed
in from outside are owned by the provider.
3. **Mutating observables outside actions** — use `runInAction()`, `@action`, or `@bindable`.
Expand Down Expand Up @@ -305,6 +308,21 @@ server only indexes Java source. For navigating into Groovy code, use Grep/Glob
- **Database**: MySQL (or H2 in-memory for quick local dev via `APP_TOOLBOX_USE_H2=true`)
- **Package Manager**: Yarn 1.22 (frontend), Gradle via wrapper (backend)

## 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.

## Common Commands

### Frontend (run from `client-app/`)
Expand Down
3 changes: 3 additions & 0 deletions client-app/src/desktop/AppModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
columnGroupsGridPanel,
dataViewPanel,
externalSortGridPanel,
filterBuilderPanel,
inlineEditingPanel,
restGridPanel,
standardGridPanel,
Expand Down Expand Up @@ -186,6 +187,7 @@ export class AppModel extends BaseAppModel {
{name: 'rest', path: '/rest'},
{name: 'inlineEditing', path: '/inlineEditing'},
{name: 'columnFiltering', path: '/columnFiltering'},
{name: 'filterBuilder', path: '/filterBuilder'},
{name: 'externalSort', path: '/externalSort'},
{name: 'zoneGrid', path: '/zoneGrid'},
{name: 'dataview', path: '/dataview'},
Expand Down Expand Up @@ -306,6 +308,7 @@ export class AppModel extends BaseAppModel {
{id: 'standard', content: standardGridPanel},
{id: 'tree', content: treeGridPanel},
{id: 'columnFiltering', content: columnFilteringPanel},
{id: 'filterBuilder', title: 'FilterBuilder', content: filterBuilderPanel},
{id: 'inlineEditing', content: inlineEditingPanel},
{id: 'zoneGrid', title: 'Zone Grid', content: zoneGridPanel},
{id: 'dataview', title: 'DataView', content: dataViewPanel},
Expand Down
87 changes: 87 additions & 0 deletions client-app/src/desktop/tabs/grids/FilterBuilderPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {grid, gridCountLabel} from '@xh/hoist/cmp/grid';
import {filler, hframe, p, span} from '@xh/hoist/cmp/layout';
import {creates, hoistCmp} from '@xh/hoist/core';
import {filterBuilder, filterChooser} from '@xh/hoist/desktop/cmp/filter';
import {switchInput} from '@xh/hoist/desktop/cmp/input';
import {panel} from '@xh/hoist/desktop/cmp/panel';
import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
import {Icon} from '@xh/hoist/icon';
import {wrapper} from '../../common';
import {FilterBuilderPanelModel} from './FilterBuilderPanelModel';

export const filterBuilderPanel = hoistCmp.factory({
model: creates(FilterBuilderPanelModel),
render({model}) {
return wrapper({
description: [
p(
"FilterBuilder provides a visual query builder for constructing filters of arbitrary complexity. It supports nested AND/OR groups with NOT negation, type-appropriate value editors, and full integration with Hoist's filter binding system."
),
p(
'This example shows a FilterBuilder and FilterChooser both bound to the same store, demonstrating bi-directional sync. Note that FilterBuilder can construct compound filters (e.g. nested groups with NOT negation) that FilterChooser cannot represent in its compact format. When such a filter is active, the chooser will display an "Unsupported filter" placeholder.'
)
],
links: [
{
url: '$TB/client-app/src/desktop/tabs/grids/FilterBuilderPanel.ts',
notes: 'This example.'
},
{
url: '$HR/cmp/filter/FilterBuilderModel.ts',
notes: 'Hoist model for FilterBuilder component.'
},
{
url: '$HR/desktop/cmp/filter/FilterBuilder.ts',
notes: 'Desktop FilterBuilder component.'
}
],
item: panel({
title: 'Grids \u203A FilterBuilder',
icon: Icon.filter(),
className: 'tb-grid-wrapper-panel',
tbar: tbar(),
item: hframe({
flex: 1,
items: [
filterBuilder({
model: model.filterBuilderModel,
flex: 1,
minWidth: 300,
maxWidth: 500,
testId: 'filter-builder'
}),
grid({flex: 2, className: 'xh-border-left'})
]
}),
bbar: bbar()
})
});
}
});

const tbar = hoistCmp.factory<FilterBuilderPanelModel>(({model}) =>
toolbar(
filterChooser({
model: model.filterChooserModel,
flex: 1,
enableClear: true,
placeholder: 'Companion FilterChooser (synced to same store)...'
})
)
);

const bbar = hoistCmp.factory<FilterBuilderPanelModel>(({model}) =>
toolbar(
span('Commit on Change'),
switchInput({
model: model.filterBuilderModel,
bind: 'commitOnChange'
}),
span('Favorites'),
switchInput({
bind: 'enableFavorites'
}),
filler(),
gridCountLabel()
)
);
99 changes: 99 additions & 0 deletions client-app/src/desktop/tabs/grids/FilterBuilderPanelModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {FilterBuilderModel} from '@xh/hoist/cmp/filter';
import {FilterChooserModel} from '@xh/hoist/cmp/filter';
import {GridModel} from '@xh/hoist/cmp/grid';
import {HoistModel, managed, XH} from '@xh/hoist/core';
import {millionsRenderer, numberRenderer} from '@xh/hoist/format';
import {bindable, makeObservable} from '@xh/hoist/mobx';
import {
activeCol,
cityCol,
companyCol,
profitLossCol,
tradeDateCol,
tradeVolumeCol
} from '../../../core/columns';

export class FilterBuilderPanelModel extends HoistModel {
@managed gridModel: GridModel;
@managed filterBuilderModel: FilterBuilderModel;
@managed filterChooserModel: FilterChooserModel;
@bindable enableFavorites: boolean = true;

constructor() {
super();
makeObservable(this);
this.gridModel = this.createGridModel();
this.filterBuilderModel = this.createFilterBuilderModel();
this.filterChooserModel = this.createFilterChooserModel();

this.addReaction({
track: () => this.enableFavorites,
run: () => {
this.filterBuilderModel.destroy();
this.filterBuilderModel = this.createFilterBuilderModel();
}
});
}

override async doLoadAsync() {
const {trades} = await XH.fetchJson({url: 'trade'});
this.gridModel.loadData(trades);
}

private createGridModel() {
return new GridModel({
sortBy: 'profit_loss|desc|abs',
emptyText: 'No records found...',
store: {
idEncodesTreePath: true,
freezeData: false
},
colDefaults: {filterable: true},
columns: [
{field: 'id', hidden: true},
activeCol,
companyCol,
cityCol,
tradeVolumeCol,
profitLossCol,
tradeDateCol
]
});
}

private createFilterBuilderModel() {
const {store} = this.gridModel;
return new FilterBuilderModel({
bind: store,
fieldSpecs: [
'active',
'company',
'city',
'trade_date',
{field: 'profit_loss'},
{field: 'trade_volume'}
],
persistWith: this.enableFavorites
? {localStorageKey: 'toolboxFilterBuilder', persistFavorites: true}
: null
});
}

private createFilterChooserModel() {
const {store} = this.gridModel;
return new FilterChooserModel({
bind: store,
fieldSpecs: [
'active',
'company',
'city',
'trade_date',
{field: 'profit_loss', valueRenderer: numberRenderer({precision: 0})},
{
field: 'trade_volume',
valueRenderer: millionsRenderer({precision: 1, label: true})
}
]
});
}
}
1 change: 1 addition & 0 deletions client-app/src/desktop/tabs/grids/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './AgGridView';
export * from './ColumnFilteringPanel';
export * from './FilterBuilderPanel';
export * from './ColumnGroupsGridPanel';
export * from './DataViewPanel';
export * from './ExternalSortGridPanel';
Expand Down
Loading