diff --git a/CHANGELOG.md b/CHANGELOG.md index b77737445b..44065f1a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ * Clicking on the right hand clear button in a `textInput` (desktop and mobile) now maintains focus on the `textInput`, allowing a user to quickly type something else into the field. This behaviour already existed on the `select` input. +* Added `ZoneGrid`, a specialized version of the Grid component that displays its data with + multi-line full-width rows. Each row is broken into four zones for top/bottom and left/right, + each of which can mapped to render one or more fields. ### 💥 Breaking Changes diff --git a/cmp/grid/Grid.ts b/cmp/grid/Grid.ts index 86c8b1d51d..5c8e5079e8 100644 --- a/cmp/grid/Grid.ts +++ b/cmp/grid/Grid.ts @@ -125,7 +125,7 @@ export const [Grid, grid] = hoistCmp.withFactory({ } }); -(Grid as any).MULTIFIELD_ROW_HEIGHT = 38; +(Grid as any).MULTIFIELD_ROW_HEIGHT = 42; //------------------------ // Implementation diff --git a/cmp/grid/GridModel.ts b/cmp/grid/GridModel.ts index 4eea0552df..f4be543bd8 100644 --- a/cmp/grid/GridModel.ts +++ b/cmp/grid/GridModel.ts @@ -132,7 +132,7 @@ export interface GridConfig { /** Config with which to create a GridFilterModel, or `true` to enable default. Desktop only.*/ filterModel?: GridFilterModelConfig | boolean; - /** Config with which to create aColChooserModel, or boolean `true` to enable default.*/ + /** Config with which to create a ColChooserModel, or boolean `true` to enable default.*/ colChooserModel?: ColChooserConfig | boolean; /** diff --git a/cmp/grid/renderers/MultiFieldRenderer.ts b/cmp/grid/renderers/MultiFieldRenderer.ts index fdf503f3aa..8ad3ca1c4a 100644 --- a/cmp/grid/renderers/MultiFieldRenderer.ts +++ b/cmp/grid/renderers/MultiFieldRenderer.ts @@ -98,7 +98,7 @@ function renderMainField(value, renderer, context) { function renderSubField({colId, label}, context) { const {record, gridModel} = context, - column = gridModel.findColumn(gridModel.columns, colId); + column = gridModel.getColumn(colId); throwIf(!column, `Subfield ${colId} not found`); diff --git a/cmp/zoneGrid/Types.ts b/cmp/zoneGrid/Types.ts new file mode 100644 index 0000000000..23b4cf679c --- /dev/null +++ b/cmp/zoneGrid/Types.ts @@ -0,0 +1,47 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ + +import {Column, ColumnRenderer, ColumnSortSpec} from '@xh/hoist/cmp/grid'; +import {PersistOptions} from '@xh/hoist/core'; + +export type Zone = 'tl' | 'tr' | 'bl' | 'br'; + +export interface ZoneMapping { + /** Field to display. Must match a Field found in the Store */ + field: string; + /** True to prefix the field value with its name */ + showLabel?: boolean; +} + +export interface ZoneLimit { + /** Min number of fields that should be mapped to the zone */ + min?: number; + /** Max number of fields that should be mapped to the zone */ + max?: number; + /** Array of allowed fields for the zone */ + only?: string[]; +} + +export interface ZoneField { + field: string; + displayName: string; + label: string; + renderer: ColumnRenderer; + column: Column; + chooserGroup: string; + sortable: boolean; + sortingOrder: ColumnSortSpec[]; +} + +export interface ZoneGridModelPersistOptions extends PersistOptions { + /** True to include mapping information (default true) */ + persistMapping?: boolean; + /** True to include grouping information (default true) */ + persistGrouping?: boolean; + /** True to include sorting information (default true) */ + persistSort?: boolean; +} diff --git a/cmp/zoneGrid/ZoneGrid.ts b/cmp/zoneGrid/ZoneGrid.ts new file mode 100644 index 0000000000..c1ef9a1395 --- /dev/null +++ b/cmp/zoneGrid/ZoneGrid.ts @@ -0,0 +1,62 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {hoistCmp, HoistProps, LayoutProps, TestSupportProps, uses, XH} from '@xh/hoist/core'; +import {fragment} from '@xh/hoist/cmp/layout'; +import {GridOptions} from '@xh/hoist/kit/ag-grid'; +import {grid} from '@xh/hoist/cmp/grid'; +import {splitLayoutProps} from '@xh/hoist/utils/react'; +import {zoneMapper as desktopZoneMapper} from '@xh/hoist/dynamics/desktop'; +import {zoneMapper as mobileZoneMapper} from '@xh/hoist/dynamics/mobile'; +import {ZoneGridModel} from './ZoneGridModel'; + +export interface ZoneGridProps extends HoistProps, LayoutProps, TestSupportProps { + /** + * Options for ag-Grid's API. + * + * This constitutes an 'escape hatch' for applications that need to get to the underlying + * ag-Grid API. It should be used with care. Settings made here might be overwritten and/or + * interfere with the implementation of this component and its use of the ag-Grid API. + * + * Note that changes to these options after the component's initial render will be ignored. + */ + agOptions?: GridOptions; +} + +/** + * A ZoneGrid is a specialized version of the Grid component. + * + * It displays its data with multi-line full-width rows, each broken into four zones for + * top/bottom and left/right - (tl, tr, bl, br). Zone mappings determine which of the + * available fields should be extracted from the record and rendered into each zone. + */ +export const [ZoneGrid, zoneGrid] = hoistCmp.withFactory({ + displayName: 'ZoneGrid', + model: uses(ZoneGridModel), + className: 'xh-zone-grid', + + render({model, className, testId, ...props}, ref) { + const {gridModel, mapperModel} = model, + [layoutProps] = splitLayoutProps(props), + platformZoneMapper = XH.isMobileApp ? mobileZoneMapper : desktopZoneMapper; + + return fragment( + grid({ + ...layoutProps, + className, + testId, + ref, + model: gridModel, + agOptions: { + suppressRowGroupHidesColumns: true, + suppressMakeColumnVisibleAfterUnGroup: true, + ...props.agOptions + } + }), + mapperModel ? platformZoneMapper() : null + ); + } +}); diff --git a/cmp/zoneGrid/ZoneGridModel.ts b/cmp/zoneGrid/ZoneGridModel.ts new file mode 100644 index 0000000000..206474ddda --- /dev/null +++ b/cmp/zoneGrid/ZoneGridModel.ts @@ -0,0 +1,666 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import { + HoistModel, + LoadSpec, + PlainObject, + Some, + managed, + XH, + Awaitable, + VSide +} from '@xh/hoist/core'; +import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; +import { + RecordAction, + Store, + StoreConfig, + StoreRecordOrId, + StoreSelectionConfig, + StoreSelectionModel, + StoreTransaction +} from '@xh/hoist/data'; +import { + Column, + ColumnSpec, + Grid, + GridConfig, + GridContextMenuSpec, + GridGroupSortFn, + GridModel, + GridSorter, + GridSorterLike, + GroupRowRenderer, + RowClassFn, + RowClassRuleFn, + TreeStyle, + multiFieldRenderer +} from '@xh/hoist/cmp/grid'; +import { + CellClickedEvent, + CellContextMenuEvent, + CellDoubleClickedEvent, + RowClickedEvent, + RowDoubleClickedEvent +} from '@ag-grid-community/core'; +import {Icon} from '@xh/hoist/icon'; +import {throwIf, withDefault} from '@xh/hoist/utils/js'; +import {castArray, forOwn, isEmpty, isFinite, isPlainObject, isString} from 'lodash'; +import {ReactNode} from 'react'; +import {ZoneMapperConfig, ZoneMapperModel} from './impl/ZoneMapperModel'; +import {ZoneGridPersistenceModel} from './impl/ZoneGridPersistenceModel'; +import {ZoneGridModelPersistOptions, Zone, ZoneLimit, ZoneMapping} from './Types'; + +export interface ZoneGridConfig { + /** + * Available columns for this grid. Note that the actual display of + * the zone columns is managed via `mappings` below. + */ + columns: Array; + + /** Mappings of columns to zones. */ + mappings: Record>; + + /** Optional configurations for zone constraints. */ + limits?: Partial>; + + /** + * Optional configs to apply to left column. Intended for use as an `escape hatch`, and should be used with care. + * Settings made here may interfere with the implementation of this component. + */ + leftColumnSpec?: Partial; + + /** + * Optional configs to apply to right column. Intended for use as an `escape hatch`, and should be used with care. + * Settings made here may interfere with the implementation of this component. + */ + rightColumnSpec?: Partial; + + /** String rendered between consecutive SubFields. */ + delimiter?: string; + + /** Config with which to create a ZoneMapperModel, or boolean `true` to enable default. */ + zoneMapperModel?: ZoneMapperConfig | boolean; + + /** + * A Store instance, or a config with which to create a Store. If not supplied, + * store fields will be inferred from columns config. + */ + store?: Store | StoreConfig; + + /** True if grid is a tree grid (default false). */ + treeMode?: boolean; + + /** Location for a docked summary row. Requires `store.SummaryRecord` to be populated. */ + showSummary?: boolean | VSide; + + /** Specification of selection behavior. Defaults to 'single' (desktop) and 'disabled' (mobile) */ + selModel?: StoreSelectionModel | StoreSelectionConfig | 'single' | 'multiple' | 'disabled'; + + /** + * Function to be called when the user triggers ZoneGridModel.restoreDefaultsAsync(). + * This function will be called after the built-in defaults have been restored, and can be + * used to restore application specific defaults. + */ + restoreDefaultsFn?: () => Awaitable; + + /** + * Confirmation warning to be presented to user before restoring default state. Set to + * null to skip user confirmation. + */ + restoreDefaultsWarning?: ReactNode; + + /** Options governing persistence. */ + persistWith?: ZoneGridModelPersistOptions; + + /** + * Text/element to display if grid has no records. Defaults to null, in which case no empty + * text will be shown. + */ + emptyText?: ReactNode; + + /** True (default) to hide empty text until after the Store has been loaded at least once. */ + hideEmptyTextBeforeLoad?: boolean; + + /** + * Initial sort to apply to grid data. + * Note that unlike GridModel, multi-sort is not supported. + */ + sortBy?: GridSorterLike; + + /** Column ID(s) by which to do full-width grouping. */ + groupBy?: Some; + + /** True (default) to show a count of group member rows within each full-width group row. */ + showGroupRowCounts?: boolean; + + /** True to highlight the currently hovered row. */ + showHover?: boolean; + + /** True to render row borders. */ + rowBorders?: boolean; + + /** Specify treeMode-specific styling. */ + treeStyle?: TreeStyle; + + /** True to use alternating backgrounds for rows. */ + stripeRows?: boolean; + + /** True to render cell borders. */ + cellBorders?: boolean; + + /** True to highlight the focused cell with a border. */ + showCellFocus?: boolean; + + /** True to suppress display of the grid's header row. */ + hideHeaders?: boolean; + + /** + * Closure to generate CSS class names for a row. + * NOTE that, once added, classes will *not* be removed if the data changes. + * Use `rowClassRules` instead if StoreRecord data can change across refreshes. + */ + rowClassFn?: RowClassFn; + + /** + * Object keying CSS class names to functions determining if they should be added or + * removed from the row. See Ag-Grid docs on "row styles" for details. + */ + rowClassRules?: Record; + + /** Height (in px) of a group row. Note that this will override `sizingMode` for group rows. */ + groupRowHeight?: number; + + /** Function used to render group rows. */ + groupRowRenderer?: GroupRowRenderer; + + /** + * Function to use to sort full-row groups. Called with two group values to compare + * in the form of a standard JS comparator. Default is an ascending string sort. + * Set to `null` to prevent sorting of groups. + */ + groupSortFn?: GridGroupSortFn; + + /** + * Callback when a key down event is detected on the grid. Note that the ag-Grid API provides + * limited ability to customize keyboard handling. This handler is designed to allow + * applications to work around this. + */ + onKeyDown?: (e: KeyboardEvent) => void; + + /** + * Callback when a row is clicked. (Note that the event received may be null - e.g. for + * clicks on full-width group rows.) + */ + onRowClicked?: (e: RowClickedEvent) => void; + + /** + * Callback when a row is double-clicked. (Note that the event received may be null - e.g. + * for clicks on full-width group rows.) + */ + onRowDoubleClicked?: (e: RowDoubleClickedEvent) => void; + + /** + * Callback when a cell is clicked. + */ + onCellClicked?: (e: CellClickedEvent) => void; + + /** + * Callback when a cell is double-clicked. + */ + onCellDoubleClicked?: (e: CellDoubleClickedEvent) => void; + + /** + * Callback when the context menu is opened. Note that the event received can also be + * triggered via a long press (aka tap and hold) on mobile devices. + */ + onCellContextMenu?: (e: CellContextMenuEvent) => void; + + /** + * Number of clicks required to expand / collapse a parent row in a tree grid. Defaults + * to 2 for desktop, 1 for mobile. Any other value prevents clicks on row body from + * expanding / collapsing (requires click on tree col affordance to expand/collapse). + */ + clicksToExpand?: number; + + /** + * Array of RecordActions, dividers, or token strings with which to create a context menu. + * May also be specified as a function returning same. + */ + contextMenu?: GridContextMenuSpec; + + /** + * Governs if the grid should reuse a limited set of DOM elements for columns visible in the + * scroll area (versus rendering all columns). Consider this performance optimization for + * grids with a very large number of columns obscured by horizontal scrolling. Note that + * setting this value to true may limit the ability of the grid to autosize offscreen columns + * effectively. Default false. + */ + useVirtualColumns?: boolean; + + /** + * Set to true to if application will be reloading data when the sortBy property changes on + * this model (either programmatically, or via user-click.) Useful for applications with large + * data sets that are performing external, or server-side sorting and filtering. Setting this + * flag means that the grid should not immediately respond to user or programmatic changes to + * the sortBy property, but will instead wait for the next load of data, which is assumed to be + * pre-sorted. Default false. + */ + externalSort?: boolean; + + /** + * Set to true to highlight a row on click. Intended to provide feedback to users in grids + * without selection. Note this setting overrides the styling used by Column.highlightOnChange, + * and is not recommended for use alongside that feature. Default true for mobiles, + * otherwise false. + */ + highlightRowOnClick?: boolean; + + /** + * Flags for experimental features. These features are designed for early client-access and + * testing, but are not yet part of the Hoist API. + */ + experimental?: PlainObject; + + /** Extra app-specific data for the GridModel. */ + appData?: PlainObject; + + /** @internal */ + xhImpl?: boolean; +} + +/** + * ZoneGridModel is a wrapper around GridModel, which shows date in a grid with multi-line + * full-width rows, each broken into four zones for top/bottom and left/right. + * + * This is the primary app entry-point for specifying ZoneGrid component options and behavior. + */ +export class ZoneGridModel extends HoistModel { + @managed + gridModel: GridModel; + + @managed + mapperModel: ZoneMapperModel; + + @observable.ref + mappings: Record; + + @bindable.ref + leftColumnSpec: Partial; + + @bindable.ref + rightColumnSpec: Partial; + + availableColumns: ColumnSpec[]; + limits: Partial>; + delimiter: string; + restoreDefaultsFn: () => Awaitable; + restoreDefaultsWarning: ReactNode; + + private _defaultState; // initial state provided to ctor - powers restoreDefaults(). + @managed persistenceModel: ZoneGridPersistenceModel; + + constructor(config: ZoneGridConfig) { + super(); + makeObservable(this); + + const { + columns, + limits, + mappings, + sortBy, + groupBy, + leftColumnSpec, + rightColumnSpec, + delimiter, + zoneMapperModel, + restoreDefaultsFn, + restoreDefaultsWarning, + persistWith, + ...rest + } = config; + + this.availableColumns = columns.map(it => ({...it, hidden: true})); + this.limits = limits; + this.mappings = this.parseMappings(mappings); + + this.leftColumnSpec = leftColumnSpec; + this.rightColumnSpec = rightColumnSpec; + this.delimiter = delimiter ?? ' • '; + this.restoreDefaultsFn = restoreDefaultsFn; + this.restoreDefaultsWarning = restoreDefaultsWarning; + + this._defaultState = { + mappings: this.mappings, + sortBy: sortBy, + groupBy: groupBy + }; + + this.gridModel = this.createGridModel(rest); + + this.setSortBy(sortBy); + this.setGroupBy(groupBy); + + this.mapperModel = this.parseMapperModel(zoneMapperModel); + this.persistenceModel = persistWith + ? new ZoneGridPersistenceModel(this, persistWith) + : null; + + this.addReaction({ + track: () => [this.leftColumnSpec, this.rightColumnSpec], + run: () => this.gridModel.setColumns(this.getColumns()) + }); + } + + /** + * Restore the mapping, sorting, and grouping configs as specified by the application at + * construction time. This is the state without any user changes applied. + * This method will clear the persistent grid state saved for this grid, if any. + * + * @returns true if defaults were restored + */ + async restoreDefaultsAsync(): Promise { + if (this.restoreDefaultsWarning) { + const confirmed = await XH.confirm({ + title: 'Please Confirm', + icon: Icon.warning(), + message: this.restoreDefaultsWarning, + confirmProps: { + text: 'Yes, restore defaults', + intent: 'primary' + } + }); + if (!confirmed) return false; + } + + const {mappings, sortBy, groupBy} = this._defaultState; + this.setMappings(mappings); + this.setSortBy(sortBy); + this.setGroupBy(groupBy); + + this.persistenceModel?.clear(); + + if (this.restoreDefaultsFn) { + await this.restoreDefaultsFn(); + } + + return true; + } + + showMapper() { + this.mapperModel.open(); + } + + @action + setMappings(mappings: Record>) { + this.mappings = this.parseMappings(mappings); + this.gridModel.setColumns(this.getColumns()); + } + + getDefaultContextMenu = () => [ + 'filter', + '-', + 'copy', + 'copyWithHeaders', + 'copyCell', + '-', + 'expandCollapseAll', + '-', + 'restoreDefaults', + '-', + new RecordAction({ + text: 'Customize Fields', + icon: Icon.gridLarge(), + hidden: !this?.mapperModel, + actionFn: () => (this?.mapperModel as any)?.open() + }) + ]; + + //----------------------- + // Getters and methods trampolined from GridModel. + //----------------------- + get sortBy(): GridSorter { + const ret = this.gridModel.sortBy?.[0]; + if (!ret) return null; + + // Normalize 'left_column' and 'right_column' to actual underlying fields + if (ret?.colId === 'left_column') { + const colId = this.mappings.tl[0]?.field; + return colId ? new GridSorter({...ret, colId}) : null; + } else if (ret?.colId === 'right_column') { + const colId = this.mappings.tr[0]?.field; + return colId ? new GridSorter({...ret, colId}) : null; + } + + return ret; + } + + setSortBy(cfg: GridSorterLike) { + // If the field is mapping to the primary field in a left/right column, set + // 'left_column'/'right_column' colId instead to display the arrows in the header. + const sorter = GridSorter.parse(cfg); + if (sorter?.colId === this.mappings.tl[0]?.field) { + return this.gridModel.setSortBy({...sorter, colId: 'left_column'}); + } + if (sorter?.colId === this.mappings.tr[0]?.field) { + return this.gridModel.setSortBy({...sorter, colId: 'right_column'}); + } + return this.gridModel.setSortBy(sorter); + } + + get store() { + return this.gridModel.store; + } + + get empty() { + return this.gridModel.empty; + } + + get selModel() { + return this.gridModel.selModel; + } + + get hasSelection() { + return this.gridModel.hasSelection; + } + + get selectedRecords() { + return this.gridModel.selectedRecords; + } + + get selectedRecord() { + return this.gridModel.selectedRecord; + } + + get selectedId() { + return this.gridModel.selectedId; + } + + get groupBy() { + return this.gridModel.groupBy; + } + + selectAsync( + records: Some, + opts: {ensureVisible?: boolean; clearSelection?: boolean} + ) { + return this.gridModel.selectAsync(records, opts); + } + + preSelectFirstAsync() { + return this.gridModel.preSelectFirstAsync(); + } + + selectFirstAsync(opts: {ensureVisible?: boolean} = {}) { + return this.gridModel.selectFirstAsync(opts); + } + + ensureSelectionVisibleAsync() { + return this.gridModel.ensureSelectionVisibleAsync(); + } + + override doLoadAsync(loadSpec: LoadSpec) { + return this.gridModel.doLoadAsync(loadSpec); + } + + loadData(rawData: any[], rawSummaryData?: PlainObject) { + return this.gridModel.loadData(rawData, rawSummaryData); + } + + updateData(rawData: PlainObject[] | StoreTransaction) { + return this.gridModel.updateData(rawData); + } + + clear() { + return this.gridModel.clear(); + } + + setGroupBy(colIds: Some) { + return this.gridModel.setGroupBy(colIds); + } + + //----------------------- + // Implementation + //----------------------- + private createGridModel(config: GridConfig): GridModel { + return new GridModel({ + ...config, + xhImpl: true, + contextMenu: withDefault(config.contextMenu, this.getDefaultContextMenu), + sizingMode: 'standard', + cellBorders: true, + rowBorders: true, + stripeRows: false, + autosizeOptions: {mode: 'disabled'}, + columns: this.getColumns() + }); + } + + private getColumns(): ColumnSpec[] { + return [ + this.buildZoneColumn(true), + this.buildZoneColumn(false), + // Ensure all available columns are provided as hidden columns for lookup by multifield renderer + ...this.availableColumns + ]; + } + + private buildZoneColumn(isLeft: boolean): ColumnSpec { + const topMappings = this.mappings[isLeft ? 'tl' : 'tr'], + bottomMappings = this.mappings[isLeft ? 'bl' : 'br']; + + throwIf( + isEmpty(topMappings), + `${isLeft ? 'Left' : 'Right'} column requires at least one top mapping` + ); + + // Extract the primary column from the top mappings + const primaryCol = new Column(this.findColumnSpec(topMappings[0]), this.gridModel); + + // Extract the sub-fields from the other mappings + const subFields = []; + topMappings.slice(1).forEach(it => { + subFields.push({colId: it.field, label: it.showLabel, position: 'top'}); + }); + bottomMappings.forEach(it => { + subFields.push({colId: it.field, label: it.showLabel, position: 'bottom'}); + }); + + return { + // Controlled properties + field: isLeft ? 'left_column' : 'right_column', + flex: isLeft ? 2 : 1, + align: isLeft ? 'left' : 'right', + renderer: multiFieldRenderer, + rowHeight: Grid['MULTIFIELD_ROW_HEIGHT'], + resizable: false, + movable: false, + hideable: false, + appData: { + multiFieldConfig: { + mainRenderer: primaryCol.renderer, + delimiter: this.delimiter, + subFields + } + }, + + // Properties inherited from primary column + headerName: primaryCol.headerName, + absSort: primaryCol.absSort, + sortingOrder: primaryCol.sortingOrder, + sortValue: primaryCol.sortValue, + sortToBottom: primaryCol.sortToBottom, + comparator: primaryCol.comparator, + sortable: primaryCol.sortable, + getValueFn: primaryCol.getValueFn, + + // Optional overrides + ...(isLeft ? this.leftColumnSpec : this.rightColumnSpec) + }; + } + + private findColumnSpec(mapping: ZoneMapping): ColumnSpec { + return this.availableColumns.find(it => { + const {field} = it; + return isString(field) ? field === mapping.field : field.name === mapping.field; + }); + } + + private parseMappings( + mappings: Record> + ): Record { + const ret = {} as Record; + forOwn(mappings, (rawMapping, zone) => { + // 1) Standardize mapping into an array of ZoneMappings + const mapping = []; + castArray(rawMapping).forEach(it => { + if (!it) return; + + const ret = isString(it) ? {field: it} : it, + col = this.findColumnSpec(ret); + + throwIf(!col, `Column not found for field ${ret.field}`); + return mapping.push(ret); + }); + + // 2) Ensure mapping respects configured limits + const limit = this.limits?.[zone]; + if (limit) { + throwIf( + isFinite(limit.min) && mapping.length < limit.min, + `Requires minimum ${limit.min} mappings in zone "${zone}"` + ); + throwIf( + isFinite(limit.max) && mapping.length > limit.max, + `Exceeds maximum ${limit.max} mappings in zone "${zone}"` + ); + + if (!isEmpty(limit.only)) { + mapping.forEach(it => { + throwIf( + !limit.only.includes(it.field), + `Field "${it.field}" not allowed in zone "${zone}"` + ); + }); + } + } + + ret[zone] = mapping; + }); + return ret; + } + + private parseMapperModel(mapperModel: ZoneMapperConfig | boolean): ZoneMapperModel { + if (isPlainObject(mapperModel)) { + return new ZoneMapperModel({ + ...(mapperModel as ZoneMapperConfig), + zoneGridModel: this + }); + } + return mapperModel ? new ZoneMapperModel({zoneGridModel: this}) : null; + } +} diff --git a/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts b/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts new file mode 100644 index 0000000000..24a8a7cd3e --- /dev/null +++ b/cmp/zoneGrid/impl/ZoneGridPersistenceModel.ts @@ -0,0 +1,143 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {HoistModel, managed, PersistenceProvider, PlainObject} from '@xh/hoist/core'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import {isUndefined} from 'lodash'; +import {ZoneGridModel} from '../ZoneGridModel'; +import {ZoneGridModelPersistOptions} from '../Types'; + +/** + * Model to manage persisting state from ZoneGridModel. + * @internal + */ +export class ZoneGridPersistenceModel extends HoistModel { + override xhImpl = true; + + VERSION = 1; // Increment to abandon state. + + zoneGridModel: ZoneGridModel; + + @observable.ref + state: PlainObject; + + @managed + provider: PersistenceProvider; + + constructor(zoneGridModel: ZoneGridModel, config: ZoneGridModelPersistOptions) { + super(); + makeObservable(this); + + this.zoneGridModel = zoneGridModel; + + let { + persistMapping = true, + persistGrouping = true, + persistSort = true, + ...persistWith + } = config; + + persistWith = {path: 'zoneGrid', ...persistWith}; + + // 1) Read state from and attach to provider -- fail gently + try { + this.provider = PersistenceProvider.create(persistWith); + this.state = this.loadState() ?? {version: this.VERSION}; + this.addReaction({ + track: () => this.state, + run: state => this.provider.write(state) + }); + } catch (e) { + console.error(e); + this.state = {version: this.VERSION}; + } + + // 2) Bind self to grid, and populate grid. + if (persistMapping) { + this.updateGridMapping(); + this.addReaction(this.mappingReaction()); + } + + if (persistGrouping) { + this.updateGridGroupBy(); + this.addReaction(this.groupReaction()); + } + + if (persistSort) { + this.updateGridSort(); + this.addReaction(this.sortReaction()); + } + } + + @action + clear() { + this.state = {version: this.VERSION}; + } + + //-------------------------- + // Columns + //-------------------------- + mappingReaction() { + return { + track: () => this.zoneGridModel.mappings, + run: mappings => { + this.patchState({mappings}); + } + }; + } + + updateGridMapping() { + const {mappings} = this.state; + if (!isUndefined(mappings)) this.zoneGridModel.setMappings(mappings); + } + + //-------------------------- + // Sort + //-------------------------- + sortReaction() { + return { + track: () => this.zoneGridModel.sortBy, + run: sortBy => { + this.patchState({sortBy: sortBy?.toString()}); + } + }; + } + + updateGridSort() { + const {sortBy} = this.state; + if (!isUndefined(sortBy)) this.zoneGridModel.setSortBy(sortBy); + } + + //-------------------------- + // Grouping + //-------------------------- + groupReaction() { + return { + track: () => this.zoneGridModel.groupBy, + run: groupBy => { + this.patchState({groupBy}); + } + }; + } + + updateGridGroupBy() { + const {groupBy} = this.state; + if (!isUndefined(groupBy)) this.zoneGridModel.setGroupBy(groupBy); + } + + //-------------------------- + // Other Implementation + //-------------------------- + @action + patchState(updates) { + this.state = {...this.state, ...updates}; + } + + loadState() { + const ret = this.provider.read(); + return ret?.version === this.VERSION ? ret : null; + } +} diff --git a/cmp/zoneGrid/impl/ZoneMapperModel.ts b/cmp/zoneGrid/impl/ZoneMapperModel.ts new file mode 100644 index 0000000000..da39185d6a --- /dev/null +++ b/cmp/zoneGrid/impl/ZoneMapperModel.ts @@ -0,0 +1,335 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {HoistModel, XH} from '@xh/hoist/core'; +import {span} from '@xh/hoist/cmp/layout'; +import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {StoreRecord} from '@xh/hoist/data'; +import {GridSorter} from '@xh/hoist/cmp/grid'; +import {Icon} from '@xh/hoist/icon'; +import {cloneDeep, findIndex, isBoolean, isEmpty, isEqual, isFinite, isString} from 'lodash'; +import {ReactNode} from 'react'; +import {ZoneGridModel} from '../ZoneGridModel'; +import {ZoneField, Zone, ZoneLimit, ZoneMapping} from '../Types'; + +export interface ZoneMapperConfig { + /** The ZoneGridModel to be configured. */ + zoneGridModel: ZoneGridModel; + + /** True (default) to show Reset button to restore default configuration. */ + showRestoreDefaults?: boolean; + + /** True (default) to group columns by their chooserGroup */ + groupColumns?: boolean; +} + +/** + * State management for the ZoneMapper component. + * + * It is not necessary to manually create instances of this class within an application. + * + * @internal + */ +export class ZoneMapperModel extends HoistModel { + zoneGridModel: ZoneGridModel; + showRestoreDefaults: boolean; + groupColumns: boolean; + + // Show in dialog + @observable isOpen: boolean = false; + + // Show in popover (desktop only) + @observable isPopoverOpen = false; + + @bindable + selectedZone: Zone = 'tl'; + + @observable.ref + mappings: Record; + + @observable.ref + sortBy: GridSorter; + + fields: ZoneField[] = []; + sampleRecord: StoreRecord; + + @computed + get isDirty(): boolean { + const {mappings, sortBy} = this.zoneGridModel; + return !isEqual(this.mappings, mappings) || !isEqual(this.sortBy, sortBy); + } + + get leftFlex(): number { + const ret = this.zoneGridModel.leftColumnSpec?.flex; + return isBoolean(ret) ? 1 : ret ?? 2; + } + + get rightFlex(): number { + const ret = this.zoneGridModel.rightColumnSpec?.flex; + return isBoolean(ret) ? 1 : ret ?? 1; + } + + get limits(): Partial> { + return this.zoneGridModel.limits; + } + + get delimiter(): string { + return this.zoneGridModel.delimiter; + } + + get sortByColId() { + return this.sortBy?.colId; + } + + get sortByOptions() { + return this.fields + .filter(it => it.sortable) + .map(it => { + const {field, displayName} = it; + return {value: field, label: displayName}; + }); + } + + constructor(config: ZoneMapperConfig) { + super(); + makeObservable(this); + + const {zoneGridModel, showRestoreDefaults = true, groupColumns = true} = config; + + this.zoneGridModel = zoneGridModel; + this.showRestoreDefaults = showRestoreDefaults; + this.groupColumns = groupColumns; + this.fields = this.getFields(); + + this.addReaction({ + track: () => XH.routerState, + run: () => this.close() + }); + } + + async restoreDefaultsAsync() { + const restored = await this.zoneGridModel.restoreDefaultsAsync(); + if (restored) this.close(); + } + + @action + open() { + this.syncMapperData(); + this.isOpen = true; + } + + @action + openPopover() { + this.syncMapperData(); + this.isPopoverOpen = true; + } + + @action + close() { + this.isOpen = false; + this.isPopoverOpen = false; + } + + commit() { + this.zoneGridModel.setMappings(this.mappings); + this.zoneGridModel.setSortBy(this.sortBy); + } + + getSamplesForZone(zone: Zone): ReactNode[] { + return this.mappings[zone].map(mapping => { + return this.getSampleForMapping(mapping); + }); + } + + getSortLabel() { + const {sortBy} = this; + if (!sortBy) return null; + if (sortBy.abs) return 'Abs'; + return sortBy.sort === 'asc' ? 'Asc' : 'Desc'; + } + + getSortIcon() { + const {sortBy} = this; + if (!sortBy) return null; + const {abs, sort} = sortBy; + if (sort === 'asc') { + return abs ? Icon.sortAbsAsc() : Icon.sortAsc(); + } else if (sort === 'desc') { + return abs ? Icon.sortAbsDesc() : Icon.sortDesc(); + } + } + + //------------------------ + // Sorting + //------------------------ + @action + setSortByColId(colId: string) { + const {sortingOrder} = this.fields.find(it => it.field === colId); + + // Default direction|abs to first entry in sortingOrder + this.sortBy = GridSorter.parse({colId, ...sortingOrder[0]}); + } + + @action + setNextSortBy() { + const {colId, sort, abs} = this.sortBy, + {sortingOrder} = this.fields.find(it => it.field === colId), + currIdx = findIndex(sortingOrder, {sort, abs}), + nextIdx = isFinite(currIdx) ? (currIdx + 1) % sortingOrder.length : 0; + + this.sortBy = GridSorter.parse({colId, ...sortingOrder[nextIdx]}); + } + + //------------------------ + // Zone Mappings + //------------------------ + toggleShown(field: string) { + const {selectedZone} = this, + currMapping = this.getMappingForFieldAndZone(selectedZone, field); + + if (currMapping) { + this.removeZoneMapping(selectedZone, field); + } else { + this.addZoneMapping(selectedZone, field); + } + } + + toggleShowLabel(field: string) { + const {selectedZone} = this, + currMapping = this.getMappingForFieldAndZone(selectedZone, field); + + this.addOrAdjustZoneMapping(selectedZone, field, { + showLabel: !currMapping?.showLabel + }); + } + + @action + private syncMapperData() { + // Copy latest mappings and sortBy from grid + const {mappings, sortBy} = this.zoneGridModel; + this.mappings = cloneDeep(mappings); + this.sortBy = sortBy ? cloneDeep(sortBy) : null; + + // Take sample record from grid + this.sampleRecord = this.getSampleRecord(); + } + + private getFields(): ZoneField[] { + const {zoneGridModel} = this; + return zoneGridModel.availableColumns.map(it => { + const fieldName = isString(it.field) ? it.field : it.field.name, + column = zoneGridModel.gridModel.getColumn(fieldName), + displayName = column.displayName, + label = isString(it.headerName) ? it.headerName : displayName; + + return { + field: fieldName, + displayName: displayName, + label: label, + column: column, + renderer: column.renderer, + chooserGroup: column.chooserGroup, + sortable: column.sortable, + sortingOrder: column.sortingOrder + }; + }); + } + + private addOrAdjustZoneMapping(zone: Zone, field: string, adjustment: Partial) { + const currMapping = this.getMappingForFieldAndZone(zone, field); + if (currMapping) { + this.adjustZoneMapping(zone, field, adjustment); + } else { + this.addZoneMapping(zone, field, adjustment); + } + } + + @action + private adjustZoneMapping(zone: Zone, field: string, adjustment: Partial) { + const currMapping = this.getMappingForFieldAndZone(zone, field); + if (!currMapping) return; + + let mappings = cloneDeep(this.mappings); + mappings[zone] = mappings[zone].map(it => { + return it.field === field ? {...it, ...adjustment} : it; + }); + this.mappings = mappings; + } + + @action + private addZoneMapping(zone: Zone, field: string, config: Partial = {}) { + const allowedFields = this.limits?.[zone]?.only, + maxFields = this.limits?.[zone]?.max; + + if (!isEmpty(allowedFields) && !allowedFields.includes(field)) return; + + let mappings = cloneDeep(this.mappings); + + // Drop the last (right-most) value(s) as needed to ensure we don't overflow max + const zoneCount = mappings[zone].length; + if (maxFields && zoneCount >= maxFields) { + mappings[zone].splice(zoneCount - 1, zoneCount - maxFields + 1); + } + + // Add the new mapping + mappings[zone].push({...config, field}); + this.mappings = mappings; + } + + private removeZoneMapping(zone: Zone, field: string) { + let mappings = cloneDeep(this.mappings); + mappings[zone] = mappings[zone].filter(it => it.field !== field); + + const minFields = this.limits?.[zone]?.min; + if (!minFields || mappings[zone].length >= minFields) { + this.mappings = mappings; + } + } + + private getMappingForFieldAndZone(zone: Zone, field: string): ZoneMapping { + return this.mappings[zone].find(it => it.field === field); + } + + //------------------------ + // Sample Display + //------------------------ + getSampleForMapping(mapping: ZoneMapping): ReactNode { + const {fields, sampleRecord} = this, + field = fields.find(it => it.field === mapping.field); + + if (!field) return null; + + let value; + if (sampleRecord) { + value = sampleRecord.data[mapping.field]; + if (field.renderer) { + value = field.renderer(value, { + record: sampleRecord, + column: field.column, + gridModel: this.zoneGridModel.gridModel + }); + } + } + + // Display a placeholder if the sample record is missing a value for the field + if (isEmpty(value)) { + return span(`[${field.displayName}]`); + } + + // Render label if requested + const label = mapping.showLabel ? `${field.label}: ` : null; + return span(label, value); + } + + private getSampleRecord(): StoreRecord { + // Iterate down to a (likely more fully populated) leaf record. + let ret = this.zoneGridModel.store.records[0]; + while (ret && !isEmpty(ret.children)) { + ret = ret.children[0]; + } + return ret; + } +} diff --git a/cmp/zoneGrid/index.ts b/cmp/zoneGrid/index.ts new file mode 100644 index 0000000000..e47c3d0e53 --- /dev/null +++ b/cmp/zoneGrid/index.ts @@ -0,0 +1,3 @@ +export * from './Types'; +export * from './ZoneGrid'; +export * from './ZoneGridModel'; diff --git a/desktop/appcontainer/AppContainer.ts b/desktop/appcontainer/AppContainer.ts index ab31be8821..6c3491ffd2 100644 --- a/desktop/appcontainer/AppContainer.ts +++ b/desktop/appcontainer/AppContainer.ts @@ -13,6 +13,7 @@ import {suspendPanel} from '@xh/hoist/desktop/appcontainer/SuspendPanel'; import {dockContainerImpl} from '@xh/hoist/desktop/cmp/dock/impl/DockContainer'; import {colChooserDialog as colChooser} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooserDialog'; import {ColChooserModel} from '@xh/hoist/desktop/cmp/grid/impl/colchooser/ColChooserModel'; +import {zoneMapperDialog as zoneMapper} from '@xh/hoist/desktop/cmp/zoneGrid/impl/ZoneMapperDialog'; import {columnHeaderFilter} from '@xh/hoist/desktop/cmp/grid/impl/filter/ColumnHeaderFilter'; import {ColumnHeaderFilterModel} from '@xh/hoist/desktop/cmp/grid/impl/filter/ColumnHeaderFilterModel'; import {gridFilterDialog} from '@xh/hoist/desktop/cmp/grid/impl/filter/GridFilterDialog'; @@ -48,6 +49,7 @@ installDesktopImpls({ storeFilterFieldImpl, pinPadImpl, colChooser, + zoneMapper, columnHeaderFilter, gridFilterDialog, ColChooserModel, diff --git a/desktop/cmp/button/ZoneMapperButton.ts b/desktop/cmp/button/ZoneMapperButton.ts new file mode 100644 index 0000000000..c3d4833fd4 --- /dev/null +++ b/desktop/cmp/button/ZoneMapperButton.ts @@ -0,0 +1,90 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import '@xh/hoist/desktop/register'; +import {hoistCmp, useContextModel} from '@xh/hoist/core'; +import {div, vbox} from '@xh/hoist/cmp/layout'; +import {ZoneGridModel} from '@xh/hoist/cmp/zoneGrid'; +import {ZoneMapperModel} from '@xh/hoist/cmp/zoneGrid/impl/ZoneMapperModel'; +import {zoneMapper} from '@xh/hoist/desktop/cmp/zoneGrid/impl/ZoneMapper'; +import {Icon} from '@xh/hoist/icon'; +import {popover, Position} from '@xh/hoist/kit/blueprint'; +import {stopPropagation, withDefault} from '@xh/hoist/utils/js'; +import {MENU_PORTAL_ID} from '@xh/hoist/desktop/cmp/input'; +import {button, ButtonProps} from './Button'; + +export interface ZoneMapperButtonProps extends ButtonProps { + /** ZoneGridModel of the grid for which this button should show a chooser. */ + zoneGridModel?: ZoneGridModel; + + /** Position for chooser popover, as per Blueprint docs. */ + popoverPosition?: Position; +} + +/** + * A convenience button to trigger the display of a ZoneMapper UI for ZoneGrid configuration. + * + * Requires a `ZoneGridModel.zoneMapperModel` config option, set to true for default implementation. + */ +export const [ZoneMapperButton, zoneMapperButton] = hoistCmp.withFactory({ + displayName: 'ZoneMapperButton', + model: false, + render({icon, title, zoneGridModel, popoverPosition, disabled, ...rest}, ref) { + zoneGridModel = withDefault(zoneGridModel, useContextModel(ZoneGridModel)); + + const mapperModel = zoneGridModel?.mapperModel as ZoneMapperModel; + + if (!zoneGridModel) { + console.error( + "No ZoneGridModel available to ZoneMapperButton. Provide via a 'zoneGridModel' prop, or context." + ); + disabled = true; + } + + if (!mapperModel) { + console.error( + 'No ZoneMapperModel available on bound ZoneGridModel - enable via ZoneGridModel.zoneMapperModel config.' + ); + disabled = true; + } + + const isOpen = mapperModel?.isPopoverOpen; + return popover({ + isOpen, + popoverClassName: 'xh-zone-mapper-popover xh-popup--framed', + position: withDefault(popoverPosition, 'auto'), + target: button({ + icon: withDefault(icon, Icon.gridLarge()), + title: withDefault(title, 'Customize fields...'), + disabled, + ...rest + }), + disabled, + content: vbox({ + onClick: stopPropagation, + onDoubleClick: stopPropagation, + items: [ + div({ref, className: 'xh-popup__title', item: 'Customize Fields'}), + zoneMapper({model: mapperModel}) + ] + }), + onInteraction: (willOpen, e?) => { + if (isOpen && !willOpen) { + // Prevent clicks with Select controls from closing popover + const selectPortal = document.getElementById(MENU_PORTAL_ID), + selectPortalClick = selectPortal?.contains(e?.target), + selectValueClick = e?.target?.classList.contains('xh-select__single-value'); + + if (!selectPortalClick && !selectValueClick) { + mapperModel.close(); + } + } else if (!isOpen && willOpen) { + mapperModel.openPopover(); + } + } + }); + } +}); diff --git a/desktop/cmp/button/index.ts b/desktop/cmp/button/index.ts index 8b0f2a3ffe..aaa726dd0d 100644 --- a/desktop/cmp/button/index.ts +++ b/desktop/cmp/button/index.ts @@ -14,3 +14,4 @@ export * from './OptionsButton'; export * from './RefreshButton'; export * from './RestoreDefaultsButton'; export * from './ThemeToggleButton'; +export * from './ZoneMapperButton'; diff --git a/desktop/cmp/grid/impl/colchooser/ColChooser.ts b/desktop/cmp/grid/impl/colchooser/ColChooser.ts index 386f361fa1..1e017118cf 100644 --- a/desktop/cmp/grid/impl/colchooser/ColChooser.ts +++ b/desktop/cmp/grid/impl/colchooser/ColChooser.ts @@ -43,7 +43,7 @@ export const colChooser = hoistCmp.factory({ filler(), button({ omit: !showRestoreDefaults, - text: 'Restore Grid Defaults', + text: 'Restore Defaults', icon: Icon.undo({className: 'xh-red'}), onClick: () => model.restoreDefaultsAsync() }), @@ -57,7 +57,8 @@ export const colChooser = hoistCmp.factory({ button({ omit: commitOnChange, text: 'Save', - icon: Icon.check({className: 'xh-green'}), + icon: Icon.check(), + intent: 'success', onClick: () => { model.commit(); model.close(); diff --git a/desktop/cmp/zoneGrid/impl/ZoneMapper.scss b/desktop/cmp/zoneGrid/impl/ZoneMapper.scss new file mode 100644 index 0000000000..215099045d --- /dev/null +++ b/desktop/cmp/zoneGrid/impl/ZoneMapper.scss @@ -0,0 +1,71 @@ +.xh-zone-mapper { + width: 350px; + height: 500px; + + &__zone-picker { + padding: var(--xh-pad-px); + background: var(--xh-bg-alt); + border-bottom: var(--xh-border-solid); + + &__zone-cell { + display: flex; + align-items: center; + overflow: hidden; + flex: 1; + min-height: 30px; + white-space: nowrap; + padding: var(--xh-pad-half-px); + background: var(--xh-bg); + border: var(--xh-border-solid); + cursor: pointer; + + & > span:not(:last-child) { + margin-right: 3px; + } + + &--selected { + box-shadow: var(--xh-form-field-focused-box-shadow); + background: var(--xh-grid-tree-group-bg); + } + + &.tl { + font-size: var(--xh-grid-multifield-top-font-size-px); + border-radius: var(--xh-border-radius-px) 0 0 0; + } + + &.tr { + justify-content: flex-end; + margin: 0 0 0 -1px; + font-size: var(--xh-grid-multifield-top-font-size-px); + border-radius: 0 var(--xh-border-radius-px) 0 0; + } + + &.bl { + margin: -1px 0 0 0; + font-size: var(--xh-grid-multifield-bottom-font-size-px); + border-radius: 0 0 0 var(--xh-border-radius-px); + } + + &.br { + justify-content: flex-end; + margin: -1px 0 0 -1px; + font-size: var(--xh-grid-multifield-bottom-font-size-px); + border-radius: 0 0 var(--xh-border-radius-px); + } + } + } + + &__sort-picker { + flex: none !important; + border-top: var(--xh-border-solid); + + .xh-panel__content .xh-hframe { + padding: var(--xh-pad-px); + align-items: center; + + & > *:not(:last-child) { + margin-right: var(--xh-pad-px); + } + } + } +} diff --git a/desktop/cmp/zoneGrid/impl/ZoneMapper.ts b/desktop/cmp/zoneGrid/impl/ZoneMapper.ts new file mode 100644 index 0000000000..0f1689a29a --- /dev/null +++ b/desktop/cmp/zoneGrid/impl/ZoneMapper.ts @@ -0,0 +1,232 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import '@xh/hoist/desktop/register'; +import {hoistCmp, HoistModel, lookup, managed, useLocalModel, uses} from '@xh/hoist/core'; +import {div, filler, hbox, hframe, span, vbox} from '@xh/hoist/cmp/layout'; +import {panel} from '@xh/hoist/desktop/cmp/panel'; +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {checkbox} from '@xh/hoist/desktop/cmp/input'; +import {button} from '@xh/hoist/desktop/cmp/button'; +import {select} from '@xh/hoist/desktop/cmp/input'; +import {Icon} from '@xh/hoist/icon'; +import {intersperse} from '@xh/hoist/utils/js'; +import {isEmpty} from 'lodash'; +import classNames from 'classnames'; +import './ZoneMapper.scss'; +import {ZoneMapperModel} from '@xh/hoist/cmp/zoneGrid/impl/ZoneMapperModel'; + +/** + * Hoist UI for user selection and discovery of available ZoneGrid columns, enabled via the + * `ZoneGridModel.zoneMapperModel` config option. + * + * This component displays an example of each of the four zones, with the available columns for + * the currently selected zone displayed in a list below. Users can toggle column visibility + * and labels for each zone to construct a custom layout for the grid rows. + * + * It is not necessary to manually create instances of this component within an application. + * + * @internal + */ +export const [ZoneMapper, zoneMapper] = hoistCmp.withFactory({ + displayName: 'ZoneMapper', + model: uses(ZoneMapperModel), + className: 'xh-zone-mapper', + render({model, className}) { + const {showRestoreDefaults, isDirty} = model, + impl = useLocalModel(ZoneMapperLocalModel); + + return panel({ + className, + items: [zonePicker(), grid({model: impl.gridModel}), sortPicker()], + bbar: [ + button({ + omit: !showRestoreDefaults, + text: 'Restore Defaults', + icon: Icon.undo({className: 'xh-red'}), + onClick: () => model.restoreDefaultsAsync() + }), + filler(), + button({ + text: 'Cancel', + onClick: () => model.close() + }), + button({ + text: 'Save', + icon: Icon.check(), + intent: 'success', + disabled: !isDirty, + onClick: () => { + model.commit(); + model.close(); + } + }) + ] + }); + } +}); + +const zonePicker = hoistCmp.factory({ + render({model}) { + const {leftFlex, rightFlex} = model, + className = 'xh-zone-mapper__zone-picker'; + + return vbox({ + className, + items: [ + hbox({ + className: `${className}__top`, + items: [ + zoneCell({zone: 'tl', flex: leftFlex}), + zoneCell({zone: 'tr', flex: rightFlex}) + ] + }), + hbox({ + className: `${className}__bottom`, + items: [ + zoneCell({zone: 'bl', flex: leftFlex}), + zoneCell({zone: 'br', flex: rightFlex}) + ] + }) + ] + }); + } +}); + +const zoneCell = hoistCmp.factory({ + render({model, zone, flex}) { + const {selectedZone, delimiter} = model, + className = 'xh-zone-mapper__zone-picker__zone-cell', + samples = model.getSamplesForZone(zone); + + return div({ + className: classNames( + className, + zone, + selectedZone === zone ? `${className}--selected` : null + ), + style: {flex}, + onClick: () => (model.selectedZone = zone), + items: intersperse(samples, span(delimiter)) + }); + } +}); + +const sortPicker = hoistCmp.factory({ + render({model}) { + return panel({ + className: 'xh-zone-mapper__sort-picker', + title: 'Sorting', + icon: Icon.list(), + compactHeader: true, + items: hframe( + select({ + bind: 'sortByColId', + enableFilter: true, + flex: 1, + options: model.sortByOptions + }), + button({ + text: model.getSortLabel(), + icon: model.getSortIcon(), + width: 80, + minimal: false, + onClick: () => model.setNextSortBy() + }) + ) + }); + } +}); + +class ZoneMapperLocalModel extends HoistModel { + override xhImpl = true; + @lookup(ZoneMapperModel) model: ZoneMapperModel; + + @managed + gridModel: GridModel; + + override onLinked() { + super.onLinked(); + + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => [ + this.model.isOpen, + this.model.isPopoverOpen, + this.model.mappings, + this.model.selectedZone + ], + run: () => this.syncGrid(), + fireImmediately: true + }); + } + + private createGridModel(): GridModel { + const {model} = this, + {groupColumns, fields} = model, + hasGrouping = groupColumns && fields.some(it => it.chooserGroup); + + return new GridModel({ + store: {idSpec: 'field'}, + groupBy: hasGrouping ? 'chooserGroup' : null, + colDefaults: {movable: false, resizable: false, sortable: false}, + selModel: 'disabled', + columns: [ + { + field: 'displayName', + headerName: 'Field', + flex: 1 + }, + { + field: 'show', + align: 'center', + renderer: (value, {record}) => { + const {field} = record.data; + return checkbox({value, onChange: () => model.toggleShown(field)}); + } + }, + { + field: 'showLabel', + headerName: 'Label', + align: 'center', + renderer: (value, {record}) => { + const {label, field} = record.data; + if (!label) return null; + return checkbox({value, onChange: () => model.toggleShowLabel(field)}); + } + }, + // Hidden + {field: 'field', hidden: true}, + {field: 'label', hidden: true}, + {field: 'chooserGroup', hidden: true} + ] + }); + } + + private syncGrid() { + const {fields, mappings, limits, selectedZone} = this.model, + mapping = mappings[selectedZone], + limit = limits?.[selectedZone], + data = []; + + // 1) Determine which fields are shown and labeled for the zone + const allowedFields = !isEmpty(limit?.only) + ? fields.filter(it => limit.only.includes(it.field)) + : fields; + + allowedFields.forEach(f => { + const fieldMapping = mapping.find(it => f.field === it.field), + show = !!fieldMapping, + showLabel = fieldMapping?.showLabel ?? false; + + data.push({...f, show, showLabel}); + }); + + // 2) Load into display grid + this.gridModel.loadData(data); + } +} diff --git a/desktop/cmp/zoneGrid/impl/ZoneMapperDialog.ts b/desktop/cmp/zoneGrid/impl/ZoneMapperDialog.ts new file mode 100644 index 0000000000..56aba74199 --- /dev/null +++ b/desktop/cmp/zoneGrid/impl/ZoneMapperDialog.ts @@ -0,0 +1,35 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {hoistCmp, uses} from '@xh/hoist/core'; +import {Icon} from '@xh/hoist/icon'; +import {dialog} from '@xh/hoist/kit/blueprint'; +import {ZoneMapperModel} from '@xh/hoist/cmp/zoneGrid/impl/ZoneMapperModel'; +import {zoneMapper} from './ZoneMapper'; + +export const zoneMapperDialog = hoistCmp.factory({ + model: uses(ZoneMapperModel), + className: 'xh-zone-mapper-dialog', + + render({model, className}) { + const {isOpen} = model; + if (!isOpen) return null; + + return dialog({ + className, + icon: Icon.gridLarge(), + title: 'Customize Fields', + isOpen: true, + onClose: () => model.close(), + item: zoneMapper({model}), + // Size determined by inner component + style: { + width: 'unset', + height: 'unset' + } + }); + } +}); diff --git a/dynamics/desktop.ts b/dynamics/desktop.ts index bf215cda1f..6fe4c99a6d 100644 --- a/dynamics/desktop.ts +++ b/dynamics/desktop.ts @@ -19,6 +19,7 @@ export let ColChooserModel = null; export let ColumnHeaderFilterModel = null; export let ModalSupportModel = null; export let colChooser = null; +export let zoneMapper = null; export let columnHeaderFilter = null; export let dockContainerImpl = null; export let errorMessage = null; @@ -38,6 +39,7 @@ export function installDesktopImpls(impls) { ColumnHeaderFilterModel = impls.ColumnHeaderFilterModel; ModalSupportModel = impls.ModalSupportModel; colChooser = impls.colChooser; + zoneMapper = impls.zoneMapper; columnHeaderFilter = impls.columnHeaderFilter; dockContainerImpl = impls.dockContainerImpl; errorMessage = impls.errorMessage; diff --git a/dynamics/mobile.ts b/dynamics/mobile.ts index ce8047bed8..d9972fe7b9 100644 --- a/dynamics/mobile.ts +++ b/dynamics/mobile.ts @@ -17,6 +17,7 @@ */ export let ColChooserModel = null; export let colChooser = null; +export let zoneMapper = null; export let errorMessage = null; export let pinPadImpl = null; export let storeFilterFieldImpl = null; @@ -30,6 +31,7 @@ export let tabContainerImpl = null; export function installMobileImpls(impls) { ColChooserModel = impls.ColChooserModel; colChooser = impls.colChooser; + zoneMapper = impls.zoneMapper; errorMessage = impls.errorMessage; pinPadImpl = impls.pinPadImpl; storeFilterFieldImpl = impls.storeFilterFieldImpl; diff --git a/mobile/appcontainer/AppContainer.ts b/mobile/appcontainer/AppContainer.ts index bb376a240e..2ca32d64d5 100644 --- a/mobile/appcontainer/AppContainer.ts +++ b/mobile/appcontainer/AppContainer.ts @@ -11,6 +11,7 @@ import {createElement, hoistCmp, refreshContextView, uses, XH} from '@xh/hoist/c import {installMobileImpls} from '@xh/hoist/dynamics/mobile'; import {colChooser} from '@xh/hoist/mobile/cmp/grid/impl/ColChooser'; import {ColChooserModel} from '@xh/hoist/mobile/cmp/grid/impl/ColChooserModel'; +import {zoneMapper} from '@xh/hoist/mobile/cmp/zoneGrid/impl/ZoneMapper'; import {mask} from '@xh/hoist/mobile/cmp/mask'; import {storeFilterFieldImpl} from '@xh/hoist/mobile/cmp/store/impl/StoreFilterField'; import {tabContainerImpl} from '@xh/hoist/mobile/cmp/tab/impl/TabContainer'; @@ -38,6 +39,7 @@ installMobileImpls({ pinPadImpl, colChooser, ColChooserModel, + zoneMapper, errorMessage }); diff --git a/mobile/cmp/button/ZoneMapperButton.ts b/mobile/cmp/button/ZoneMapperButton.ts new file mode 100644 index 0000000000..1901fafcf9 --- /dev/null +++ b/mobile/cmp/button/ZoneMapperButton.ts @@ -0,0 +1,41 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {hoistCmp, useContextModel} from '@xh/hoist/core'; +import {ZoneGridModel} from '../../../cmp/zoneGrid'; +import {button, ButtonProps} from '@xh/hoist/mobile/cmp/button'; +import {Icon} from '@xh/hoist/icon'; +import {withDefault} from '@xh/hoist/utils/js'; +import '@xh/hoist/mobile/register'; + +export interface ZoneMapperButtonProps extends ButtonProps { + /** ZoneGridModel of the grid for which this button should show a chooser. */ + zoneGridModel?: ZoneGridModel; +} + +/** + * A convenience button to trigger the display of a ZoneMapper UI for ZoneGrid configuration. + * + * Requires a `ZoneGridModel.zoneMapperModel` config option, set to true for default implementation. + */ +export const [ZoneMapperButton, zoneMapperButton] = hoistCmp.withFactory({ + displayName: 'ZoneMapperButton', + model: false, + render({zoneGridModel, icon = Icon.gridLarge(), onClick, ...props}) { + zoneGridModel = withDefault(zoneGridModel, useContextModel(ZoneGridModel)); + + if (!zoneGridModel) { + console.error( + "No ZoneGridModel available to ZoneMapperButton. Provide via a 'zoneGridModel' prop, or context." + ); + return button({icon, disabled: true, ...props}); + } + + onClick = onClick ?? (() => zoneGridModel.showMapper()); + + return button({icon, onClick, ...props}); + } +}); diff --git a/mobile/cmp/button/index.ts b/mobile/cmp/button/index.ts index 211c519469..7a8f6983fc 100644 --- a/mobile/cmp/button/index.ts +++ b/mobile/cmp/button/index.ts @@ -15,3 +15,4 @@ export * from './LogoutButton'; export * from './RefreshButton'; export * from './RestoreDefaultsButton'; export * from './NavigatorBackButton'; +export * from './ZoneMapperButton'; diff --git a/mobile/cmp/input/Select.scss b/mobile/cmp/input/Select.scss index 811221cd89..bdde39aa22 100644 --- a/mobile/cmp/input/Select.scss +++ b/mobile/cmp/input/Select.scss @@ -107,6 +107,7 @@ &__fullscreen-wrapper { position: absolute !important; + z-index: inherit; top: 0; left: 0; right: 0; diff --git a/mobile/cmp/input/Select.ts b/mobile/cmp/input/Select.ts index 5c67f5238f..1cd18dda9c 100644 --- a/mobile/cmp/input/Select.ts +++ b/mobile/cmp/input/Select.ts @@ -57,6 +57,12 @@ export interface SelectProps extends HoistProps, HoistInputProps, LayoutProps { */ enableFullscreen?: boolean; + /** + * Optional override for fullscreen z-index. Useful for enabling fullscreen from + * within components that have a higher z-index. + */ + fullScreenZIndex?: number; + /** * Function called to filter available options for a given query string input. * Used for filtering of options provided by `options` prop when `enableFilter` is true. @@ -531,6 +537,7 @@ class SelectInputModel extends HoistInputModel { portal.id = FULLSCREEN_PORTAL_ID; document.body.appendChild(portal); } + portal.style.zIndex = withDefault(this.componentProps.fullScreenZIndex, null); return portal; } diff --git a/mobile/cmp/panel/DialogPanel.scss b/mobile/cmp/panel/DialogPanel.scss index 43004c3d61..5db1ebe21c 100644 --- a/mobile/cmp/panel/DialogPanel.scss +++ b/mobile/cmp/panel/DialogPanel.scss @@ -1,10 +1,22 @@ -.xh-dialog-panel > .dialog { - width: 95%; - height: 95%; +.xh-dialog-panel { + border: var(--xh-border-solid); - .dialog-container, - .dialog-container > .xh-panel { + & > .dialog { + margin-top: var(--xh-pad-half-px); width: 100%; - height: 100%; + height: calc(100% - var(--xh-pad-px)); + max-height: calc(100% - var(--xh-pad-px)) !important; + + .dialog-container { + max-width: 100% !important; + height: 100%; + border-width: var(--xh-popup-border-width-px) 0 0 !important; + border-radius: 0; + + & > .xh-panel { + width: 100%; + height: 100%; + } + } } } diff --git a/mobile/cmp/panel/DialogPanel.ts b/mobile/cmp/panel/DialogPanel.ts index 97c120548a..fed6d48803 100644 --- a/mobile/cmp/panel/DialogPanel.ts +++ b/mobile/cmp/panel/DialogPanel.ts @@ -16,10 +16,12 @@ export interface DialogPanelProps extends PanelProps { } /** - * Wraps a Panel in a fullscreen Dialog. + * Wraps a Panel in a fullscreen floating Dialog. * * These views do not participate in navigation or routing, and are used for showing fullscreen * views outside of the Navigator / TabContainer context. + * + * @see FullscreenPanel for a true fullscreen, non-floating alternative. */ export const [DialogPanel, dialogPanel] = hoistCmp.withFactory({ displayName: 'DialogPanel', diff --git a/mobile/cmp/zoneGrid/impl/ZoneMapper.scss b/mobile/cmp/zoneGrid/impl/ZoneMapper.scss new file mode 100644 index 0000000000..5f33eac73b --- /dev/null +++ b/mobile/cmp/zoneGrid/impl/ZoneMapper.scss @@ -0,0 +1,67 @@ +.xh-zone-mapper { + &__zone-picker { + padding: var(--xh-pad-px); + background: var(--xh-bg-alt); + border-bottom: var(--xh-border-solid); + + &__zone-cell { + display: flex; + align-items: center; + overflow: hidden; + flex: 1; + min-height: 34px; + white-space: nowrap; + padding: var(--xh-pad-half-px); + background: var(--xh-bg); + border: var(--xh-border-solid); + + & > span:not(:last-child) { + margin-right: 3px; + } + + &--selected { + box-shadow: inset 0 0 1px 1px var(--xh-list-select-color); + background: var(--xh-grid-tree-group-bg); + } + + &.tl { + font-size: var(--xh-grid-multifield-top-font-size-px); + border-radius: var(--xh-border-radius-px) 0 0 0; + } + + &.tr { + justify-content: flex-end; + margin: 0 0 0 -1px; + font-size: var(--xh-grid-multifield-top-font-size-px); + border-radius: 0 var(--xh-border-radius-px) 0 0; + } + + &.bl { + margin: -1px 0 0 0; + font-size: var(--xh-grid-multifield-bottom-font-size-px); + border-radius: 0 0 0 var(--xh-border-radius-px); + } + + &.br { + justify-content: flex-end; + margin: -1px 0 0 -1px; + font-size: var(--xh-grid-multifield-bottom-font-size-px); + border-radius: 0 0 var(--xh-border-radius-px); + } + } + } + + &__sort-picker { + flex: none !important; + border-top: var(--xh-border-solid); + + .xh-panel__content .xh-hframe { + padding: var(--xh-pad-px); + align-items: center; + + & > *:not(:last-child) { + margin-right: var(--xh-pad-px); + } + } + } +} diff --git a/mobile/cmp/zoneGrid/impl/ZoneMapper.ts b/mobile/cmp/zoneGrid/impl/ZoneMapper.ts new file mode 100644 index 0000000000..1b3abe7147 --- /dev/null +++ b/mobile/cmp/zoneGrid/impl/ZoneMapper.ts @@ -0,0 +1,236 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import '@xh/hoist/mobile/register'; +import {hoistCmp, HoistModel, lookup, managed, useLocalModel, uses} from '@xh/hoist/core'; +import {div, filler, hbox, hframe, span, vbox} from '@xh/hoist/cmp/layout'; +import {dialogPanel, panel} from '@xh/hoist/mobile/cmp/panel'; +import {grid, GridModel} from '@xh/hoist/cmp/grid'; +import {checkbox} from '@xh/hoist/mobile/cmp/input'; +import {button} from '@xh/hoist/mobile/cmp/button'; +import {select} from '@xh/hoist/mobile/cmp/input'; +import {Icon} from '@xh/hoist/icon'; +import {wait} from '@xh/hoist/promise'; +import {intersperse} from '@xh/hoist/utils/js'; +import {isEmpty} from 'lodash'; +import classNames from 'classnames'; +import './ZoneMapper.scss'; +import {ZoneMapperModel} from '@xh/hoist/cmp/zoneGrid/impl/ZoneMapperModel'; + +/** + * Hoist UI for user selection and discovery of available ZoneGrid columns, enabled via the + * `ZoneGridModel.zoneMapperModel` config option. + * + * This component displays an example of each of the four zones, with the available columns for + * the currently selected zone displayed in a list below. Users can toggle column visibility + * and labels for each zone to construct a custom layout for the grid rows. + * + * It is not necessary to manually create instances of this component within an application. + * + * @internal + */ +export const [ZoneMapper, zoneMapper] = hoistCmp.withFactory({ + displayName: 'ZoneMapper', + model: uses(ZoneMapperModel), + className: 'xh-zone-mapper', + render({model, className}) { + const {isOpen, showRestoreDefaults, isDirty} = model, + impl = useLocalModel(ZoneMapperLocalModel); + + return dialogPanel({ + isOpen, + title: 'Customize Fields', + icon: Icon.gridLarge(), + className, + items: [zonePicker(), grid({model: impl.gridModel}), sortPicker()], + bbar: [ + button({ + omit: !showRestoreDefaults, + text: 'Reset', + minimal: true, + onClick: () => model.restoreDefaultsAsync() + }), + filler(), + button({ + text: 'Cancel', + minimal: true, + onClick: () => model.close() + }), + button({ + text: 'Save', + icon: Icon.check(), + disabled: !isDirty, + onClick: () => { + model.commit(); + model.close(); + } + }) + ] + }); + } +}); + +const zonePicker = hoistCmp.factory({ + render({model}) { + const {leftFlex, rightFlex} = model, + className = 'xh-zone-mapper__zone-picker'; + + return vbox({ + className, + items: [ + hbox({ + className: `${className}__top`, + items: [ + zoneCell({zone: 'tl', flex: leftFlex}), + zoneCell({zone: 'tr', flex: rightFlex}) + ] + }), + hbox({ + className: `${className}__bottom`, + items: [ + zoneCell({zone: 'bl', flex: leftFlex}), + zoneCell({zone: 'br', flex: rightFlex}) + ] + }) + ] + }); + } +}); + +const zoneCell = hoistCmp.factory({ + render({model, zone, flex}) { + const {selectedZone, delimiter} = model, + className = 'xh-zone-mapper__zone-picker__zone-cell', + samples = model.getSamplesForZone(zone); + + return div({ + className: classNames( + className, + zone, + selectedZone === zone ? `${className}--selected` : null + ), + style: {flex}, + onClick: () => (model.selectedZone = zone), + items: intersperse(samples, span(delimiter)) + }); + } +}); + +const sortPicker = hoistCmp.factory({ + render({model}) { + return panel({ + title: 'Sorting', + icon: Icon.list(), + className: 'xh-zone-mapper__sort-picker', + items: hframe( + select({ + bind: 'sortByColId', + enableFilter: true, + enableFullscreen: true, + title: 'Sorting', + fullScreenZIndex: 10002, + flex: 1, + options: model.sortByOptions + }), + button({ + text: model.getSortLabel(), + icon: model.getSortIcon(), + width: 80, + onClick: () => model.setNextSortBy() + }) + ) + }); + } +}); + +class ZoneMapperLocalModel extends HoistModel { + override xhImpl = true; + @lookup(ZoneMapperModel) model: ZoneMapperModel; + + @managed + gridModel: GridModel; + + override onLinked() { + super.onLinked(); + + this.gridModel = this.createGridModel(); + + this.addReaction({ + track: () => [this.model.isOpen, this.model.mappings, this.model.selectedZone], + run: () => this.syncGridAsync() + }); + } + + private createGridModel(): GridModel { + const {model} = this, + {groupColumns, fields} = model, + hasGrouping = groupColumns && fields.some(it => it.chooserGroup); + + return new GridModel({ + store: {idSpec: 'field'}, + groupBy: hasGrouping ? 'chooserGroup' : null, + colDefaults: {movable: false, resizable: false, sortable: false}, + columns: [ + { + field: 'displayName', + headerName: 'Field', + flex: 1 + }, + { + field: 'show', + align: 'center', + renderer: (value, {record}) => { + const {field} = record.data; + return checkbox({value, onChange: () => model.toggleShown(field)}); + } + }, + { + field: 'showLabel', + headerName: 'Label', + align: 'center', + renderer: (value, {record}) => { + const {label, field} = record.data; + if (!label) return null; + return checkbox({value, onChange: () => model.toggleShowLabel(field)}); + } + }, + // Hidden + {field: 'field', hidden: true}, + {field: 'label', hidden: true}, + {field: 'chooserGroup', hidden: true} + ] + }); + } + + private async syncGridAsync() { + const {fields, mappings, limits, selectedZone} = this.model, + mapping = mappings[selectedZone], + limit = limits?.[selectedZone], + data = []; + + // 1) Determine which fields are shown and labeled for the zone + const allowedFields = !isEmpty(limit?.only) + ? fields.filter(it => limit.only.includes(it.field)) + : fields; + + allowedFields.forEach(f => { + const fieldMapping = mapping.find(it => f.field === it.field), + show = !!fieldMapping, + showLabel = fieldMapping?.showLabel ?? false; + + data.push({...f, show, showLabel}); + }); + + // 2) Load into display grid + this.gridModel.loadData(data); + + // 3) Blur checkboxes. This is a workaround for an Onsen issue on mobile, where the checkbox + // will not re-render as long as it has focus. + await wait(1); + const checkboxes = document.querySelectorAll('ons-checkbox'); + checkboxes.forEach(it => it.blur()); + } +} diff --git a/styles/vars.scss b/styles/vars.scss index a7a85eb1d6..04e41c1c4d 100644 --- a/styles/vars.scss +++ b/styles/vars.scss @@ -495,11 +495,11 @@ body { --xh-grid-tiny-header-lr-pad-px: calc(var(--xh-grid-tiny-header-lr-pad) * 1px); // Multifield renderer - --xh-grid-multifield-top-font-size: var(--grid-multifield-top-font-size, 12); + --xh-grid-multifield-top-font-size: var(--grid-multifield-top-font-size, 14); --xh-grid-multifield-top-font-size-px: calc(var(--xh-grid-multifield-top-font-size) * 1px); - --xh-grid-multifield-bottom-font-size: var(--grid-multifield-bottom-font-size, 10); + --xh-grid-multifield-bottom-font-size: var(--grid-multifield-bottom-font-size, 11); --xh-grid-multifield-bottom-font-size-px: calc(var(--xh-grid-multifield-bottom-font-size) * 1px); - --xh-grid-multifield-line-height: var(--grid-multifield-line-height, 14); + --xh-grid-multifield-line-height: var(--grid-multifield-line-height, 16); --xh-grid-multifield-line-height-px: calc(var(--xh-grid-multifield-line-height) * 1px); // Grid column-header-based filter popover (desktop only) diff --git a/utils/js/LangUtils.ts b/utils/js/LangUtils.ts index 4f2e605afb..cac34539c2 100644 --- a/utils/js/LangUtils.ts +++ b/utils/js/LangUtils.ts @@ -6,6 +6,7 @@ */ import {Exception} from '@xh/hoist/core/exception/Exception'; import { + flatMap, forOwn, isArray, isEmpty, @@ -283,6 +284,15 @@ export function filterConsecutive( }; } +/** + * Intersperse a separator between each item in an array. + */ +export function intersperse(arr: T[], separator: T): T[] { + return flatMap(arr, (it, idx) => { + return idx > 0 ? [separator, it] : [it]; + }); +} + /** * Return value passed or the result of executing it, if it is a function. */