From b56a4ea988a0edddc0a2d1612cd6bd3e96a0861f Mon Sep 17 00:00:00 2001 From: John Haynes Date: Mon, 13 Apr 2026 12:04:55 -0400 Subject: [PATCH 1/5] Task 1: Add ColumnChooser test panel to Toolbox grids tab Add ColumnChooserPanel with a grouped-column GridModel and side-by-side layout showing both the grid and the new columnChooser component. Wire up route and tab entry in AppModel. Co-Authored-By: Claude Sonnet 4.6 --- client-app/src/desktop/AppModel.ts | 7 +- .../desktop/tabs/grids/ColumnChooserPanel.ts | 113 ++++++++++++++++++ client-app/src/desktop/tabs/grids/index.ts | 1 + 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts diff --git a/client-app/src/desktop/AppModel.ts b/client-app/src/desktop/AppModel.ts index ad9f87b8c..984579a72 100755 --- a/client-app/src/desktop/AppModel.ts +++ b/client-app/src/desktop/AppModel.ts @@ -27,6 +27,7 @@ import {examplesTab} from './tabs/examples/ExamplesTab'; import {formPanel, inputsPanel, pickerPanel, selectPanel, toolbarFormPanel} from './tabs/forms'; import { agGridView, + columnChooserPanel, columnFilteringPanel, columnGroupsGridPanel, dataViewPanel, @@ -189,7 +190,8 @@ export class AppModel extends BaseAppModel { {name: 'externalSort', path: '/externalSort'}, {name: 'zoneGrid', path: '/zoneGrid'}, {name: 'dataview', path: '/dataview'}, - {name: 'agGrid', path: '/agGrid'} + {name: 'agGrid', path: '/agGrid'}, + {name: 'columnChooser', path: '/columnChooser'} ] }, { @@ -321,7 +323,8 @@ export class AppModel extends BaseAppModel { }, {id: 'externalSort', content: externalSortGridPanel}, {id: 'rest', title: 'REST Editor', content: restGridPanel}, - {id: 'agGrid', title: 'ag-Grid Wrapper', content: agGridView} + {id: 'agGrid', title: 'ag-Grid Wrapper', content: agGridView}, + {id: 'columnChooser', title: 'Column Chooser', content: columnChooserPanel} ] } }, diff --git a/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts b/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts new file mode 100644 index 000000000..09215996f --- /dev/null +++ b/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts @@ -0,0 +1,113 @@ +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {filler, hframe, p} from '@xh/hoist/cmp/layout'; +import {storeFilterField} from '@xh/hoist/cmp/store'; +import {creates, hoistCmp, HoistModel, managed, XH} from '@xh/hoist/core'; +import {colChooserButton, exportButton} from '@xh/hoist/desktop/cmp/button'; +import {columnChooser} from '@xh/hoist/desktop/cmp/grid'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {Icon} from '@xh/hoist/icon'; +import {makeObservable} from '@xh/hoist/mobx'; +import {wrapper} from '../../common'; +import { + actualGrossCol, + actualUnitsSoldCol, + cityCol, + firstNameCol, + fullNameCol, + lastNameCol, + projectedGrossCol, + projectedUnitsSoldCol, + retainCol, + salaryCol, + stateCol +} from '../../../core/columns'; + +export const columnChooserPanel = hoistCmp.factory({ + model: creates(() => ColumnChooserPanelModel), + render({model}) { + return wrapper({ + description: [ + p( + 'The new ColumnChooser component provides a modern interface for managing grid column visibility, ordering, and pinning with drag-and-drop support and column group hierarchy.' + ) + ], + item: panel({ + title: 'Grids › Column Chooser', + icon: Icon.gridPanel(), + className: 'tb-grid-wrapper-panel', + item: hframe( + panel({ + flex: 1, + item: grid({model: model.gridModel}), + tbar: [ + filler(), + storeFilterField({gridModel: model.gridModel}), + colChooserButton({gridModel: model.gridModel}), + exportButton({gridModel: model.gridModel}) + ] + }), + columnChooser({ + gridModel: model.gridModel, + width: 350 + }) + ) + }) + }); + } +}); + +class ColumnChooserPanelModel extends HoistModel { + @managed gridModel: GridModel; + + constructor() { + super(); + makeObservable(this); + this.gridModel = new GridModel({ + store: { + idSpec: data => `${data.firstName}~${data.lastName}~${data.city}~${data.state}` + }, + sortBy: 'lastName', + emptyText: 'No records found...', + colChooserModel: true, + enableExport: true, + columns: [ + { + groupId: 'demographics', + children: [ + {...fullNameCol}, + {...firstNameCol, hidden: true}, + {...lastNameCol, hidden: true}, + {...cityCol, hidden: true}, + {...stateCol} + ] + }, + {...salaryCol}, + { + groupId: 'sales', + headerName: 'Sales', + headerAlign: 'center', + children: [ + { + groupId: 'projected', + borders: false, + headerAlign: 'center', + children: [{...projectedUnitsSoldCol}, {...projectedGrossCol}] + }, + { + groupId: 'actual', + borders: false, + headerAlign: 'center', + children: [{...actualUnitsSoldCol}, {...actualGrossCol}] + } + ] + }, + {...retainCol} + ] + }); + } + + override async doLoadAsync(loadSpec) { + const sales = await XH.fetchJson({url: 'sales'}); + this.gridModel.loadData(sales); + } +} diff --git a/client-app/src/desktop/tabs/grids/index.ts b/client-app/src/desktop/tabs/grids/index.ts index d08523ede..daf6709fd 100644 --- a/client-app/src/desktop/tabs/grids/index.ts +++ b/client-app/src/desktop/tabs/grids/index.ts @@ -1,4 +1,5 @@ export * from './AgGridView'; +export * from './ColumnChooserPanel'; export * from './ColumnFilteringPanel'; export * from './ColumnGroupsGridPanel'; export * from './DataViewPanel'; From abe6fb252e6e428acb4ec8e11ef0a2af2894c94c Mon Sep 17 00:00:00 2001 From: John Haynes Date: Mon, 13 Apr 2026 13:05:21 -0400 Subject: [PATCH 2/5] Register RowDragModule and fix ColumnChooser test panel layout - Add RowDragModule to AG Grid community module registration (required for row drag features) - Add minWidth to ColumnChooser in test panel to prevent flex shrinking Co-Authored-By: Claude Opus 4.6 (1M context) --- client-app/src/Bootstrap.ts | 2 ++ client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client-app/src/Bootstrap.ts b/client-app/src/Bootstrap.ts index c151ce040..b157141ae 100755 --- a/client-app/src/Bootstrap.ts +++ b/client-app/src/Bootstrap.ts @@ -59,6 +59,7 @@ import { RenderApiModule, RowApiModule, RowAutoHeightModule, + RowDragModule, RowSelectionModule, RowStyleModule, ScrollApiModule, @@ -76,6 +77,7 @@ ModuleRegistry.registerModules([ RenderApiModule, RowApiModule, RowAutoHeightModule, + RowDragModule, RowSelectionModule, RowStyleModule, ScrollApiModule, diff --git a/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts b/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts index 09215996f..80d8fce62 100644 --- a/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts +++ b/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts @@ -48,7 +48,8 @@ export const columnChooserPanel = hoistCmp.factory({ }), columnChooser({ gridModel: model.gridModel, - width: 350 + width: 350, + minWidth: 350 }) ) }) From 3c6e679ae3a1904ce5867a45db2903d44eb298b8 Mon Sep 17 00:00:00 2001 From: John Haynes Date: Thu, 23 Apr 2026 14:53:41 -0400 Subject: [PATCH 3/5] Checkpoint --- .../desktop/tabs/grids/ColumnChooserPanel.ts | 106 +++++++++++++++--- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts b/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts index 80d8fce62..a7399b596 100644 --- a/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts +++ b/client-app/src/desktop/tabs/grids/ColumnChooserPanel.ts @@ -1,12 +1,13 @@ import {grid, GridModel} from '@xh/hoist/cmp/grid'; -import {filler, hframe, p} from '@xh/hoist/cmp/layout'; +import {filler, hframe, p, span} from '@xh/hoist/cmp/layout'; import {storeFilterField} from '@xh/hoist/cmp/store'; import {creates, hoistCmp, HoistModel, managed, XH} from '@xh/hoist/core'; import {colChooserButton, exportButton} from '@xh/hoist/desktop/cmp/button'; import {columnChooser} from '@xh/hoist/desktop/cmp/grid'; +import {switchInput} from '@xh/hoist/desktop/cmp/input'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {Icon} from '@xh/hoist/icon'; -import {makeObservable} from '@xh/hoist/mobx'; +import {bindable, makeObservable, observable} from '@xh/hoist/mobx'; import {wrapper} from '../../common'; import { actualGrossCol, @@ -40,6 +41,11 @@ export const columnChooserPanel = hoistCmp.factory({ flex: 1, item: grid({model: model.gridModel}), tbar: [ + span('Lock Column Groups'), + switchInput({ + model, + bind: 'lockColumnGroups' + }), filler(), storeFilterField({gridModel: model.gridModel}), colChooserButton({gridModel: model.gridModel}), @@ -58,12 +64,31 @@ export const columnChooserPanel = hoistCmp.factory({ }); class ColumnChooserPanelModel extends HoistModel { - @managed gridModel: GridModel; + @managed @observable.ref gridModel: GridModel; + @bindable lockColumnGroups: boolean = true; constructor() { super(); makeObservable(this); - this.gridModel = new GridModel({ + this.gridModel = this.createGridModel(this.lockColumnGroups); + + this.addReaction({ + track: () => this.lockColumnGroups, + run: lockColumnGroups => { + XH.safeDestroy(this.gridModel); + this.gridModel = this.createGridModel(lockColumnGroups); + this.loadAsync().catchDefault(); + } + }); + } + + override async doLoadAsync(loadSpec) { + const sales = await XH.fetchJson({url: 'sales'}); + this.gridModel.loadData(sales); + } + + private createGridModel(lockColumnGroups: boolean): GridModel { + return new GridModel({ store: { idSpec: data => `${data.firstName}~${data.lastName}~${data.city}~${data.state}` }, @@ -71,18 +96,42 @@ class ColumnChooserPanelModel extends HoistModel { emptyText: 'No records found...', colChooserModel: true, enableExport: true, + lockColumnGroups, columns: [ { groupId: 'demographics', children: [ - {...fullNameCol}, - {...firstNameCol, hidden: true}, - {...lastNameCol, hidden: true}, - {...cityCol, hidden: true}, - {...stateCol} + { + ...fullNameCol, + chooserDescription: + 'Concatenation of first and last name, rendered as a single cell.' + }, + { + ...firstNameCol, + hidden: true, + chooserDescription: 'Given name of the sales representative.' + }, + { + ...lastNameCol, + hidden: true, + chooserDescription: 'Family name of the sales representative.' + }, + { + ...cityCol, + hidden: true, + chooserDescription: 'City where the sales rep is based.' + }, + { + ...stateCol, + chooserDescription: + 'Two-letter US state code where the sales rep is based.' + } ] }, - {...salaryCol}, + { + ...salaryCol, + chooserDescription: 'Base annual salary in USD, excluding bonuses.' + }, { groupId: 'sales', headerName: 'Sales', @@ -92,23 +141,44 @@ class ColumnChooserPanelModel extends HoistModel { groupId: 'projected', borders: false, headerAlign: 'center', - children: [{...projectedUnitsSoldCol}, {...projectedGrossCol}] + children: [ + { + ...projectedUnitsSoldCol, + chooserDescription: + 'Forecasted unit count for the current period, set at the start of the year.' + }, + { + ...projectedGrossCol, + chooserDescription: + 'Forecasted gross revenue (USD) based on projected units and list price.' + } + ] }, { groupId: 'actual', borders: false, headerAlign: 'center', - children: [{...actualUnitsSoldCol}, {...actualGrossCol}] + children: [ + { + ...actualUnitsSoldCol, + chooserDescription: + 'Actual unit count sold to date for the current period.' + }, + { + ...actualGrossCol, + chooserDescription: + 'Actual gross revenue (USD) recognized to date for the current period.' + } + ] } ] }, - {...retainCol} + { + ...retainCol, + chooserDescription: + 'Whether the sales rep should be retained for the next fiscal year.' + } ] }); } - - override async doLoadAsync(loadSpec) { - const sales = await XH.fetchJson({url: 'sales'}); - this.gridModel.loadData(sales); - } } From 145f875f2a9730f4ea52cacd776e8280fc97bb7e Mon Sep 17 00:00:00 2001 From: John Haynes Date: Tue, 2 Jun 2026 15:55:08 -0400 Subject: [PATCH 4/5] Checkpoint --- client-app/src/admin/AppModel.ts | 7 + .../tests/columnChooser/AddColumnDialog.ts | 172 ++++++++++++++++ .../columnChooser/ColumnChooserTestPanel.ts | 155 ++++++++++++++ .../tests/columnChooser/generateColumns.ts | 194 ++++++++++++++++++ client-app/src/admin/tests/index.ts | 1 + 5 files changed, 529 insertions(+) create mode 100644 client-app/src/admin/tests/columnChooser/AddColumnDialog.ts create mode 100644 client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts create mode 100644 client-app/src/admin/tests/columnChooser/generateColumns.ts diff --git a/client-app/src/admin/AppModel.ts b/client-app/src/admin/AppModel.ts index 5a3d45d5b..5010de504 100644 --- a/client-app/src/admin/AppModel.ts +++ b/client-app/src/admin/AppModel.ts @@ -6,6 +6,7 @@ import {PortfolioService} from '../core/svc/PortfolioService'; import {phaseRestPanel, projectRestPanel} from './roadmap'; import { asyncLoopPanel, + columnChooserTestPanel, storeColumnFilterPanel, viewColumnFilterPanel, CubeTestPanel, @@ -47,6 +48,7 @@ export class AppModel extends HoistAdminAppModel { path: '/tests', children: [ {name: 'asyncLoop', path: '/asyncLoop'}, + {name: 'columnChooser', path: '/columnChooser'}, {name: 'cube', path: '/cube'}, {name: 'dataView', path: '/dataView'}, {name: 'fetchAPI', path: '/fetchAPI'}, @@ -87,6 +89,11 @@ export class AppModel extends HoistAdminAppModel { switcher, tabs: [ {id: 'asyncLoop', title: 'Async Loops', content: asyncLoopPanel}, + { + id: 'columnChooser', + title: 'Column Chooser', + content: columnChooserTestPanel + }, {id: 'cube', title: 'Cube Data', content: CubeTestPanel}, {id: 'dataView', content: dataViewTestPanel}, {id: 'fetchAPI', title: 'Fetch API', content: FetchApiTestPanel}, diff --git a/client-app/src/admin/tests/columnChooser/AddColumnDialog.ts b/client-app/src/admin/tests/columnChooser/AddColumnDialog.ts new file mode 100644 index 000000000..93527904b --- /dev/null +++ b/client-app/src/admin/tests/columnChooser/AddColumnDialog.ts @@ -0,0 +1,172 @@ +import {ColumnSpec} from '@xh/hoist/cmp/grid'; +import {form, FormModel} from '@xh/hoist/cmp/form'; +import {filler, vbox} from '@xh/hoist/cmp/layout'; +import {hoistCmp, HoistModel, managed, uses} from '@xh/hoist/core'; +import {required} from '@xh/hoist/data'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {formField} from '@xh/hoist/desktop/cmp/form'; +import {select, switchInput, textArea, textInput} from '@xh/hoist/desktop/cmp/input'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; +import {numberRenderer} from '@xh/hoist/format'; +import {Icon} from '@xh/hoist/icon'; +import {dialog} from '@xh/hoist/kit/blueprint'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; + +/** Host that owns the grid the new column will be added to. */ +export interface AddColumnHost { + groupIds: string[]; + addColumn(spec: ColumnSpec, group: string): void; +} + +let seq = 0; + +export class AddColumnDialogModel extends HoistModel { + readonly host: AddColumnHost; + + @observable isOpen = false; + + @managed formModel = new FormModel({ + fields: [ + {name: 'chooserName', displayName: 'Name', rules: [required]}, + {name: 'chooserDescription', displayName: 'Description'}, + {name: 'type', displayName: 'Type', initialValue: 'string'}, + {name: 'group', displayName: 'Column Group'}, + {name: 'pinned', displayName: 'Pinned'}, + {name: 'excludeFromChooser', displayName: 'Exclude from Chooser', initialValue: false}, + {name: 'movable', displayName: 'Movable', initialValue: true}, + {name: 'hideable', displayName: 'Hideable', initialValue: true}, + {name: 'hidden', displayName: 'Hidden', initialValue: false} + ] + }); + + constructor(host: AddColumnHost) { + super(); + makeObservable(this); + this.host = host; + } + + @action + open() { + this.formModel.init({ + type: 'string', + movable: true, + hideable: true, + excludeFromChooser: false, + hidden: false + }); + this.isOpen = true; + } + + @action + close() { + this.isOpen = false; + } + + async submitAsync() { + const {formModel, host} = this; + if (!(await formModel.validateAsync())) return; + + const v = formModel.values, + id = `custom_${++seq}`, + spec: ColumnSpec = { + colId: id, + field: {name: id, type: v.type}, + chooserName: v.chooserName, + chooserDescription: v.chooserDescription || undefined, + headerName: v.chooserName, + excludeFromChooser: v.excludeFromChooser, + movable: v.movable, + hideable: v.hideable, + hidden: v.hidden, + width: 120 + }; + if (v.pinned) spec.pinned = v.pinned; + if (v.type === 'number') { + spec.align = 'right'; + spec.renderer = numberRenderer({precision: 0}); + } + + host.addColumn(spec, v.group || ''); + this.close(); + } +} + +export const addColumnDialog = hoistCmp.factory({ + model: uses(AddColumnDialogModel), + render({model}) { + return dialog({ + title: 'Add Column', + icon: Icon.add(), + style: {width: 460}, + isOpen: model.isOpen, + onClose: () => model.close(), + usePortal: false, + item: formContents() + }); + } +}); + +const formContents = hoistCmp.factory(({model}) => + panel({ + item: form({ + model: model.formModel, + fieldDefaults: {minimal: true, inline: false}, + item: vbox({ + className: 'xh-pad', + items: [ + formField({field: 'chooserName', item: textInput({autoFocus: true})}), + formField({field: 'chooserDescription', item: textArea({height: 60})}), + formField({ + field: 'type', + item: select({ + options: [ + {value: 'string', label: 'String'}, + {value: 'number', label: 'Number'}, + {value: 'bool', label: 'Bool'} + ] + }) + }), + formField({ + field: 'group', + item: select({ + options: model.host.groupIds, + enableCreate: true, + placeholder: '(top level)' + }) + }), + formField({ + field: 'pinned', + item: select({ + enableClear: true, + options: [ + {value: 'left', label: 'Left'}, + {value: 'right', label: 'Right'} + ] + }) + }), + formField({field: 'hidden', item: switchInput()}), + formField({field: 'hideable', item: switchInput()}), + formField({field: 'movable', item: switchInput()}), + formField({field: 'excludeFromChooser', item: switchInput()}) + ] + }) + }), + bbar: bbar() + }) +); + +const bbar = hoistCmp.factory(({model}) => + toolbar( + filler(), + button({text: 'Cancel', onClick: () => model.close()}), + button({ + text: 'Add', + icon: Icon.add(), + intent: 'success', + minimal: false, + disabled: !model.formModel.isValid, + onClick: () => model.submitAsync() + }) + ) +); diff --git a/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts b/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts new file mode 100644 index 000000000..b2e892158 --- /dev/null +++ b/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts @@ -0,0 +1,155 @@ +import {ColumnOrGroupSpec, ColumnSpec, grid, GridModel} from '@xh/hoist/cmp/grid'; +import {filler, hframe, span} from '@xh/hoist/cmp/layout'; +import {storeFilterField} from '@xh/hoist/cmp/store'; +import {creates, hoistCmp, HoistModel, managed, PlainObject, XH} from '@xh/hoist/core'; +import {button, colChooserButton, exportButton} from '@xh/hoist/desktop/cmp/button'; +import {columnChooser} from '@xh/hoist/desktop/cmp/grid'; +import {buttonGroupInput, jsonInput, switchInput} from '@xh/hoist/desktop/cmp/input'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {toolbarSep} from '@xh/hoist/desktop/cmp/toolbar'; +import {Icon} from '@xh/hoist/icon'; +import {bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {addColumnDialog, AddColumnDialogModel, AddColumnHost} from './AddColumnDialog'; +import { + collectGroupIds, + CustomColumn, + generateGridData, + GridSize, + mergeCustomColumns +} from './generateColumns'; + +export const columnChooserTestPanel = hoistCmp.factory({ + model: creates(() => ColumnChooserTestModel), + render({model}) { + return panel({ + title: 'Tests › Column Chooser', + icon: Icon.gridPanel(), + tbar: [ + span('Columns'), + buttonGroupInput({ + bind: 'size', + items: [ + button({text: 'Small', value: 'small'}), + button({text: 'Medium', value: 'medium'}), + button({text: 'Large', value: 'large'}) + ] + }), + toolbarSep(), + switchInput({bind: 'lockColumnGroups', label: 'Lock Groups', labelSide: 'left'}), + switchInput({bind: 'enableColumnPinning', label: 'Pinning', labelSide: 'left'}), + toolbarSep(), + button({ + text: 'Add Column', + icon: Icon.add(), + onClick: () => model.addColumnModel.open() + }), + filler(), + storeFilterField({gridModel: model.gridModel}), + colChooserButton({gridModel: model.gridModel}), + exportButton({gridModel: model.gridModel}) + ], + items: [ + hframe( + panel({ + title: 'Embedded Chooser', + icon: Icon.gridPanel(), + modelConfig: { + side: 'left', + defaultSize: 340, + collapsible: true, + resizable: true + }, + item: columnChooser({gridModel: model.gridModel, flex: 1}) + }), + panel({flex: 1, item: grid({model: model.gridModel})}), + panel({ + title: 'Column State', + icon: Icon.json(), + modelConfig: { + side: 'right', + defaultSize: 380, + collapsible: true, + resizable: true + }, + item: jsonInput({ + value: model.columnStateJson, + readonly: true, + language: 'json', + flex: 1, + width: '100%' + }) + }) + ), + addColumnDialog({model: model.addColumnModel}) + ] + }); + } +}); + +class ColumnChooserTestModel extends HoistModel implements AddColumnHost { + @bindable size: GridSize = 'medium'; + @bindable lockColumnGroups = true; + @bindable enableColumnPinning = true; + + @observable.ref customColumns: CustomColumn[] = []; + @managed @observable.ref gridModel: GridModel; + @managed addColumnModel = new AddColumnDialogModel(this); + + private baseColumns: ColumnOrGroupSpec[] = []; + private records: PlainObject[] = []; + + @computed + get columnStateJson(): string { + return JSON.stringify(this.gridModel.columnState, null, 2); + } + + /** Generated group ids + any new groups created via the Add Column form. */ + get groupIds(): string[] { + const base = collectGroupIds(this.baseColumns), + custom = this.customColumns.map(c => c.group).filter(g => g && !base.includes(g)); + return [...base, ...Array.from(new Set(custom))]; + } + + constructor() { + super(); + makeObservable(this); + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => [ + this.size, + this.lockColumnGroups, + this.enableColumnPinning, + this.customColumns + ], + run: () => { + XH.safeDestroy(this.gridModel); + this.gridModel = this.createGridModel(); + this.loadAsync().catchDefault(); + } + }); + } + + addColumn(spec: ColumnSpec, group: string) { + this.customColumns = [...this.customColumns, {spec, group}]; + } + + override async doLoadAsync() { + this.gridModel.loadData(this.records); + } + + private createGridModel(): GridModel { + const {columns, records} = generateGridData(this.size); + this.baseColumns = columns; + this.records = records; + return new GridModel({ + store: {idSpec: 'id'}, + emptyText: 'No records found...', + colChooserModel: true, + enableExport: true, + lockColumnGroups: this.lockColumnGroups, + enableColumnPinning: this.enableColumnPinning, + columns: mergeCustomColumns(columns, this.customColumns) + }); + } +} diff --git a/client-app/src/admin/tests/columnChooser/generateColumns.ts b/client-app/src/admin/tests/columnChooser/generateColumns.ts new file mode 100644 index 000000000..0010ad670 --- /dev/null +++ b/client-app/src/admin/tests/columnChooser/generateColumns.ts @@ -0,0 +1,194 @@ +import {ColumnOrGroupSpec, ColumnSpec} from '@xh/hoist/cmp/grid'; +import {PlainObject} from '@xh/hoist/core'; +import {numberRenderer} from '@xh/hoist/format'; +import {cloneDeep} from 'lodash'; + +export type GridSize = 'small' | 'medium' | 'large'; + +/** Custom column added interactively via the Add Column form. */ +export interface CustomColumn { + spec: ColumnSpec; + /** Target group id, or '' for top-level. New ids create a new top-level group. */ + group: string; +} + +const TYPES = ['string', 'number', 'bool'] as const; +const ROW_COUNT = 50; + +type LeafType = (typeof TYPES)[number]; + +/** Collected leaf field, used to generate matching record data. */ +interface LeafField { + name: string; + type: LeafType; +} + +/** + * Generate a deliberately-simple set of columns + matching records to stress the ColumnChooser + * across small/medium/large counts with multiple levels of group nesting. Generated columns carry + * no chooser constraints - those are exercised via the Add Column form. + * + * Each leaf `colId` encodes its group path so columns are easy to trace in the flat `columnState`, + * e.g. `g0_c3` (group 0, column 3) or `g0_g3_c7` (group 0 › subgroup 3 › column 7). Ungrouped + * top-level leaves are simply `c0`, `c1`, ... + */ +export function generateGridData(size: GridSize): { + columns: ColumnOrGroupSpec[]; + records: PlainObject[]; +} { + const fields: LeafField[] = [], + collect = (name: string, type: LeafType) => fields.push({name, type}); + + let columns: ColumnOrGroupSpec[]; + switch (size) { + case 'small': + // Couple ungrouped leaves + one shallow group. + columns = [...topLeaves(2, collect), ...buildGroups(leafTypes(4), [4], '', collect)]; + break; + case 'medium': + // Several top-level groups, each with one subgroup level. + columns = buildGroups(leafTypes(30), [12, 6], '', collect); + break; + case 'large': + // Three levels of nesting + a couple ungrouped top-level leaves. + columns = [ + ...buildGroups(leafTypes(148), [50, 25, 10], '', collect), + ...topLeaves(2, collect) + ]; + break; + } + + return {columns, records: generateRecords(fields)}; +} + +/** Merge interactively-added columns into a generated column tree (returns a fresh tree). */ +export function mergeCustomColumns( + columns: ColumnOrGroupSpec[], + customs: CustomColumn[] +): ColumnOrGroupSpec[] { + const result = cloneDeep(columns); + customs.forEach(({spec, group}) => { + if (!group) { + result.push(spec); + } else if (!appendToGroup(result, group, spec)) { + result.push({ + groupId: group, + headerName: group, + headerAlign: 'center', + children: [spec] + }); + } + }); + return result; +} + +/** Collect all group ids in a column tree, for the Add Column group selector. */ +export function collectGroupIds(columns: ColumnOrGroupSpec[]): string[] { + const ids: string[] = []; + const visit = (nodes: ColumnOrGroupSpec[]) => + nodes.forEach(node => { + if (isGroup(node)) { + ids.push(node.groupId); + visit(node.children); + } + }); + visit(columns); + return ids; +} + +//------------------------ +// Implementation +//------------------------ +/** Sequence of leaf types of the given length, cycling string/number/bool for variety. */ +function leafTypes(n: number): LeafType[] { + return Array.from({length: n}, (_, k) => TYPES[k % 3]); +} + +function topLeaves(n: number, collect: (name: string, type: LeafType) => void): ColumnSpec[] { + return leafTypes(n).map((type, k) => makeLeaf(type, `c${k}`, `Col ${k}`, collect)); +} + +function makeLeaf( + type: LeafType, + id: string, + displayName: string, + collect: (name: string, type: LeafType) => void +): ColumnSpec { + collect(id, type); + // displayName defaults both headerName and chooserName; colId still encodes the group path. + const spec: ColumnSpec = {colId: id, field: {name: id, type}, displayName, width: 120}; + if (type === 'number') { + spec.align = 'right'; + spec.renderer = numberRenderer({precision: 0}); + } + return spec; +} + +/** + * Recursively chunk leaves into nested groups - one group level per entry in `sizes`. Leaf colIds + * encode the group path (e.g. `g0_g3_c7`); `path` is the dotted index path of the current group. + */ +function buildGroups( + types: LeafType[], + sizes: number[], + path: string, + collect: (name: string, type: LeafType) => void +): ColumnOrGroupSpec[] { + if (!sizes.length) { + const prefix = path + .split('.') + .map(s => `g${s}`) + .join('_'); + return types.map((type, k) => makeLeaf(type, `${prefix}_c${k}`, `Col ${k}`, collect)); + } + const [size, ...rest] = sizes; + return chunk(types, size).map((kids, i) => { + const id = path ? `${path}.${i}` : `${i}`; + return { + groupId: `grp-${id}`, + headerName: `Group ${id}`, + headerAlign: 'center', + children: buildGroups(kids, rest, id, collect) + }; + }); +} + +function generateRecords(fields: LeafField[]): PlainObject[] { + return Array.from({length: ROW_COUNT}, (_, r) => { + const rec: PlainObject = {id: r}; + fields.forEach(({name, type}, c) => { + rec[name] = + type === 'string' + ? `R${r}·C${c}` + : type === 'number' + ? (r * 31 + c * 7) % 1000 + : (r + c) % 2 === 0; + }); + return rec; + }); +} + +function appendToGroup(nodes: ColumnOrGroupSpec[], groupId: string, spec: ColumnSpec): boolean { + for (const node of nodes) { + if (isGroup(node)) { + if (node.groupId === groupId) { + node.children.push(spec); + return true; + } + if (appendToGroup(node.children, groupId, spec)) return true; + } + } + return false; +} + +function isGroup( + node: ColumnOrGroupSpec +): node is ColumnOrGroupSpec & {groupId: string; children: ColumnOrGroupSpec[]} { + return 'children' in node; +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} diff --git a/client-app/src/admin/tests/index.ts b/client-app/src/admin/tests/index.ts index cfc7ba2ff..99f393f00 100644 --- a/client-app/src/admin/tests/index.ts +++ b/client-app/src/admin/tests/index.ts @@ -1,4 +1,5 @@ export * from './asyncLoops/AsyncLoopPanel'; +export * from './columnChooser/ColumnChooserTestPanel'; export * from './columnFilters'; export * from './cube/CubeTestPanel'; export * from './dataview/DataViewTestPanel'; From d5d6ac1bd24e82bd0e2b550cb64c9b122411d3e0 Mon Sep 17 00:00:00 2001 From: John Haynes Date: Wed, 3 Jun 2026 11:10:43 -0400 Subject: [PATCH 5/5] Checkpoint --- .../src/admin/tests/columnChooser/ColumnChooserTestPanel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts b/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts index b2e892158..bf91acec3 100644 --- a/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts +++ b/client-app/src/admin/tests/columnChooser/ColumnChooserTestPanel.ts @@ -147,6 +147,7 @@ class ColumnChooserTestModel extends HoistModel implements AddColumnHost { emptyText: 'No records found...', colChooserModel: true, enableExport: true, + useVirtualColumns: true, lockColumnGroups: this.lockColumnGroups, enableColumnPinning: this.enableColumnPinning, columns: mergeCustomColumns(columns, this.customColumns)