diff --git a/package.json b/package.json index aa5c2d9994..780662b349 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,13 @@ "rc-trigger": "patches/rc-trigger.patch" }, "onlyBuiltDependencies": [ + "@swc/core", "canvas" + ], + "ignoredBuiltDependencies": [ + "@parcel/watcher", + "core-js", + "es5-ext" ] }, "prettier": "@mendix/prettier-config-web-widgets" diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx index e50e1ec773..1a1cda173d 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx @@ -73,8 +73,7 @@ describe("Date Filter", () => { hasError: false, value: { type: "direct", store: dateFilterStore } }, - filterObserver: {} as ObservableFilterHost, - sharedInitFilter: [] + filterObserver: {} as ObservableFilterHost }; (window as any)["com.mendix.widgets.web.filterable.filterContext.v2"] = createContext( @@ -168,8 +167,7 @@ describe("Date Filter", () => { hasError: false, value: { type: "direct", store: dateFilterStore } }, - filterObserver: {} as ObservableFilterHost, - sharedInitFilter: [] + filterObserver: {} as ObservableFilterHost }; (window as any)["com.mendix.widgets.web.filterable.filterContext.v2"] = createContext( @@ -223,8 +221,7 @@ describe("Date Filter", () => { hasError: false, value: { type: "direct", store: dateFilterStore } }, - filterObserver: {} as ObservableFilterHost, - sharedInitFilter: [] + filterObserver: {} as ObservableFilterHost }; (window as any)["com.mendix.widgets.web.filterable.filterContext.v2"] = createContext( @@ -264,8 +261,7 @@ describe("Date Filter", () => { hasError: false, value: { type: "direct", store: dateFilterStore } }, - filterObserver: {} as ObservableFilterHost, - sharedInitFilter: [] + filterObserver: {} as ObservableFilterHost }; (window as any)["com.mendix.widgets.web.filterable.filterContext.v2"] = createContext( diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/hocs/withLinkedRefStore.tsx b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/hocs/withLinkedRefStore.tsx index 75ab21f9d6..76cba1bc2f 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/hocs/withLinkedRefStore.tsx +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/hocs/withLinkedRefStore.tsx @@ -106,7 +106,7 @@ class RefStoreProvider extends BaseStoreProvider { this.dataKey = gate.props.name; this._store = new RefFilterStore({ gate, - initCond: this.findInitFilter(filterAPI.sharedInitFilter, this.dataKey) + initCond: null }); } diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx index fcf8c5dd6c..39285f4101 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx @@ -53,8 +53,7 @@ const setContext = (store: NumberInputFilterStore) => { hasError: false, value: { type: "direct", store } }, - filterObserver: {} as ObservableFilterHost, - sharedInitFilter: [] + filterObserver: {} as ObservableFilterHost }; (window as any)["com.mendix.widgets.web.filterable.filterContext.v2"] = createContext(filterAPI); }; diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx index 620873af79..73cd710faf 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx @@ -50,8 +50,7 @@ const setContext = (store: StringInputFilterStore) => { hasError: false, value: { type: "direct", store } }, - filterObserver: {} as ObservableFilterHost, - sharedInitFilter: [] + filterObserver: {} as ObservableFilterHost }; (window as any)["com.mendix.widgets.web.filterable.filterContext.v2"] = createContext(filterAPI); }; diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 59cfbca641..e9714e3619 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- We removed all metadata stored in xpath to improve integration with other services. + ## [3.0.1] - 2025-08-05 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-web/package.json b/packages/pluggableWidgets/datagrid-web/package.json index 8dd955fc0d..6a48b6b060 100644 --- a/packages/pluggableWidgets/datagrid-web/package.json +++ b/packages/pluggableWidgets/datagrid-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-web", "widgetName": "Datagrid", - "version": "3.0.1", + "version": "3.0.2", "description": "", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx index 651c04806a..12db0017a1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx @@ -19,7 +19,7 @@ const FilterContext = getGlobalFilterContextObject(); function HeaderContainer(props: WidgetHeaderContextProps): ReactElement { const selectionContext = useCreateSelectionContextValue(props.selectionHelper); return ( - + {props.children} ); diff --git a/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts b/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts index d99e7f2d06..a24dae8d7f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/controllers/DatasourceParamsController.ts @@ -1,63 +1,48 @@ -import { compactArray, fromCompactArray, isAnd } from "@mendix/filter-commons/condition-utils"; import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; import { FilterCondition } from "mendix/filters"; -import { and } from "mendix/filters/builders"; -import { makeAutoObservable, reaction } from "mobx"; +import { reaction } from "mobx"; import { SortInstruction } from "../typings/sorting"; -interface Columns { - conditions: Array; - sortInstructions: SortInstruction[] | undefined; +interface ObservableFilterStore { + filter: FilterCondition | undefined; } -interface FiltersInput { - conditions: Array; +interface ObservableSortStore { + sortInstructions: SortInstruction[] | undefined; } type DatasourceParamsControllerSpec = { query: QueryController; - columns: Columns; - customFilters: FiltersInput; + filterHost: ObservableFilterStore; + sortHost: ObservableSortStore; }; export class DatasourceParamsController implements ReactiveController { - private columns: Columns; private query: QueryController; - private customFilters: FiltersInput; + private filterHost: ObservableFilterStore; + private sortHost: ObservableSortStore; constructor(host: ReactiveControllerHost, spec: DatasourceParamsControllerSpec) { host.addController(this); - this.columns = spec.columns; + this.filterHost = spec.filterHost; + this.sortHost = spec.sortHost; this.query = spec.query; - this.customFilters = spec.customFilters; - - makeAutoObservable(this, { setup: false }); - } - - private get derivedFilter(): FilterCondition | undefined { - const { columns, customFilters } = this; - - return and(compactArray(columns.conditions), compactArray(customFilters.conditions)); - } - - private get derivedSortOrder(): SortInstruction[] | undefined { - return this.columns.sortInstructions; } setup(): () => void { const [add, disposeAll] = disposeBatch(); add( reaction( - () => this.derivedSortOrder, + () => this.sortHost.sortInstructions, sortOrder => this.query.setSortOrder(sortOrder), { fireImmediately: true } ) ); add( reaction( - () => this.derivedFilter, + () => this.filterHost.filter, filter => this.query.setFilter(filter), { fireImmediately: true } ) @@ -65,21 +50,4 @@ export class DatasourceParamsController implements ReactiveController { return disposeAll; } - - static unzipFilter( - filter?: FilterCondition - ): [columns: Array, sharedFilter: Array] { - if (!filter) { - return [[], []]; - } - if (!isAnd(filter)) { - return [[], []]; - } - if (filter.args.length !== 2) { - return [[], []]; - } - - const [columns, shared] = filter.args; - return [fromCompactArray(columns), fromCompactArray(shared)]; - } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts index b3660c0767..0c26e492db 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/ColumnGroupStore.ts @@ -1,6 +1,9 @@ +import { reduceArray, restoreArray } from "@mendix/filter-commons/condition-utils"; import { FiltersSettingsMap } from "@mendix/filter-commons/typings/settings"; +import { ConditionWithMeta } from "@mendix/widget-plugin-filtering/typings/ConditionWithMeta"; +import { ObservableFilterHost } from "@mendix/widget-plugin-filtering/typings/ObservableFilterHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; -import { FilterCondition } from "mendix/filters"; + import { action, computed, makeObservable, observable } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { ColumnId, GridColumn } from "../../typings/GridColumn"; @@ -13,7 +16,7 @@ import { sortInstructionsToSortRules, sortRulesToSortInstructions } from "./ColumnsSortingStore"; -import { ColumnFilterStore, ObserverBag } from "./column/ColumnFilterStore"; +import { ColumnFilterStore } from "./column/ColumnFilterStore"; import { ColumnStore } from "./column/ColumnStore"; export interface IColumnGroupStore { @@ -40,24 +43,24 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { readonly columnFilters: ColumnFilterStore[]; + readonly metaKey = "ColumnGroupStore"; + sorting: ColumnsSortingStore; isResizing = false; constructor( props: Pick, info: StaticInfo, - initFilter: Array, - observerBag: ObserverBag + filterHost: ObservableFilterHost ) { this._allColumns = []; this.columnFilters = []; props.columns.forEach((columnProps, i) => { - const initCond = initFilter.at(i) ?? null; const column = new ColumnStore(i, columnProps, this); this._allColumnsById.set(column.columnId, column); this._allColumns[i] = column; - this.columnFilters[i] = new ColumnFilterStore(columnProps, info, initCond, observerBag); + this.columnFilters[i] = new ColumnFilterStore(columnProps, info, filterHost); }); this.sorting = new ColumnsSortingStore( @@ -72,13 +75,14 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { _allColumnsOrdered: computed, availableColumns: computed, visibleColumns: computed, - conditions: computed.struct, + condWithMeta: computed, columnSettings: computed.struct, filterSettings: computed({ keepAlive: true }), updateProps: action, setIsResizing: action, swapColumns: action, - setColumnSettings: action + setColumnSettings: action, + hydrate: action }); } @@ -142,12 +146,6 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { return [...this.availableColumns].filter(column => !column.isHidden); } - get conditions(): Array { - return this.columnFilters.map((store, index) => { - return this._allColumns[index].isHidden ? undefined : store.condition; - }); - } - get sortInstructions(): SortInstruction[] | undefined { return sortRulesToSortInstructions(this.sorting.rules, this._allColumns); } @@ -194,4 +192,21 @@ export class ColumnGroupStore implements IColumnGroupStore, IColumnParentStore { isLastVisible(column: ColumnStore): boolean { return this.visibleColumns.at(-1) === column; } + + get condWithMeta(): ConditionWithMeta { + const conditions = this.columnFilters.map((store, index) => { + return this._allColumns[index].isHidden ? undefined : store.condition; + }); + const [cond, meta] = reduceArray(conditions); + return { cond, meta }; + } + + hydrate({ cond, meta }: ConditionWithMeta): void { + restoreArray(cond, meta).forEach((condition, index) => { + const filter = this.columnFilters[index]; + if (filter && condition) { + filter.fromViewState(condition); + } + }); + } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index cc7e718a08..805a4c751a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -1,4 +1,5 @@ import { createContextWithStub, FilterAPI } from "@mendix/widget-plugin-filtering/context"; +import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter"; import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; @@ -30,40 +31,47 @@ export class RootGridStore extends BaseControllerHost { exportProgressCtrl: ProgressStore; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; - readonly autonomousFilterAPI: FilterAPI; + readonly filterAPI: FilterAPI; private gate: Gate; constructor({ gate, exportCtrl }: Spec) { super(); - const { props } = gate; - const [columnsInitFilter, sharedInitFilter] = DatasourceParamsController.unzipFilter(props.datasource.filter); this.gate = gate; + this.staticInfo = { name: props.name, filtersChannelName: `datagrid/${generateUUID()}` }; - const customFilterHost = new CustomFilterHost(); + + const filterHost = new CustomFilterHost(); + const query = new DatasourceController(this, { gate }); - this.autonomousFilterAPI = createContextWithStub({ - filterObserver: customFilterHost, - sharedInitFilter, + + this.filterAPI = createContextWithStub({ + filterObserver: filterHost, parentChannelName: this.staticInfo.filtersChannelName }); - const columns = (this.columnsStore = new ColumnGroupStore(props, this.staticInfo, columnsInitFilter, { - customFilterHost, - sharedInitFilter - })); - this.settingsStore = new GridPersonalizationStore(props, this.columnsStore, customFilterHost); + + this.columnsStore = new ColumnGroupStore(props, this.staticInfo, filterHost); + + const combinedFilter = new CombinedFilter(this, { + stableKey: props.name, + inputs: [filterHost, this.columnsStore] + }); + + this.settingsStore = new GridPersonalizationStore(props, this.columnsStore, filterHost); + this.paginationCtrl = new PaginationController(this, { gate, query }); + this.exportProgressCtrl = exportCtrl; new DatasourceParamsController(this, { query, - columns, - customFilters: customFilterHost + filterHost: combinedFilter, + sortHost: this.columnsStore }); new RefreshController(this, { @@ -73,9 +81,11 @@ export class RootGridStore extends BaseControllerHost { this.loaderCtrl = new DerivedLoaderController({ exp: exportCtrl, - cols: columns, + cols: this.columnsStore, query }); + + combinedFilter.hydrate(props.datasource.filter); } setup(): () => void { diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx index e1e3554092..e8d4e38ee4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/column/ColumnFilterStore.tsx @@ -26,12 +26,12 @@ export class ColumnFilterStore implements IColumnFilterStore { private _error: APIError | null; private _filterStore: FilterStore | null = null; private _context: FilterAPI; - private _observerBag: ObserverBag; + private _filterHost: ObservableFilterHost; - constructor(props: ColumnsType, info: StaticInfo, dsViewState: FilterCondition | null, observerBag: ObserverBag) { - this._observerBag = observerBag; + constructor(props: ColumnsType, info: StaticInfo, filterHost: ObservableFilterHost) { + this._filterHost = filterHost; this._widget = props.filter; - const storeResult = this.createFilterStore(props, dsViewState); + const storeResult = this.createFilterStore(props, null); if (storeResult === null) { this._error = this._filterStore = null; } else if (storeResult.hasError) { @@ -77,8 +77,7 @@ export class ColumnFilterStore implements IColumnFilterStore { type: "direct", store }), - filterObserver: this._observerBag.customFilterHost, - sharedInitFilter: this._observerBag.sharedInitFilter + filterObserver: this._filterHost }; } @@ -86,6 +85,10 @@ export class ColumnFilterStore implements IColumnFilterStore { return {this._widget}; } + fromViewState(cond: FilterCondition): void { + this._filterStore?.fromViewState(cond); + } + get condition(): FilterCondition | undefined { return this._filterStore ? this._filterStore.condition : undefined; } @@ -108,8 +111,3 @@ const isListAttributeValue = ( ): attribute is ListAttributeValue => { return !!(attribute && attribute.isList === false); }; - -export interface ObserverBag { - customFilterHost: ObservableFilterHost; - sharedInitFilter: Array; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/package.xml b/packages/pluggableWidgets/datagrid-web/src/package.xml index b224969655..106bea1dc1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts b/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts index 874bed0ab0..00d715be9f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts +++ b/packages/pluggableWidgets/datagrid-web/src/utils/columns-hash.ts @@ -1,25 +1,6 @@ -/* eslint-disable no-bitwise */ +import { fnv1aHash } from "@mendix/widget-plugin-grid/utils/fnv-1a-hash"; import { GridColumn } from "../typings/GridColumn"; -/** - * Generates 32 bit FNV-1a hash from the given string. - * As explained here: http://isthe.com/chongo/tech/comp/fnv/ - * - * @param s {string} String to generate hash from. - * @param [h] {number} FNV-1a hash generation init value. - * @returns {number} The result integer hash. - */ -function hash(s: string, h = 0x811c9dc5): number { - const l = s.length; - - for (let i = 0; i < l; i++) { - h ^= s.charCodeAt(i); - h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); - } - - return h >>> 0; -} - export function getHash(columns: GridColumn[], gridName: string): string { const data = JSON.stringify({ name: gridName, @@ -31,5 +12,5 @@ export function getHash(columns: GridColumn[], gridName: string): string { canDrag: col.canDrag })) }); - return hash(data).toString(); + return fnv1aHash(data).toString(); } diff --git a/packages/pluggableWidgets/gallery-web/src/controllers/QueryParamsController.ts b/packages/pluggableWidgets/gallery-web/src/controllers/QueryParamsController.ts index 84c22584a6..5e8f3e914a 100644 --- a/packages/pluggableWidgets/gallery-web/src/controllers/QueryParamsController.ts +++ b/packages/pluggableWidgets/gallery-web/src/controllers/QueryParamsController.ts @@ -1,53 +1,49 @@ -import { compactArray, fromCompactArray, isAnd } from "@mendix/filter-commons/condition-utils"; -import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { SortStoreHost } from "@mendix/widget-plugin-sorting/stores/SortStoreHost"; -import { ListValue } from "mendix"; +import { SortInstruction } from "@mendix/widget-plugin-sorting/types/store"; + import { FilterCondition } from "mendix/filters"; -import { makeAutoObservable, reaction } from "mobx"; +import { reaction } from "mobx"; + +interface ObservableFilterStore { + filter: FilterCondition | undefined; +} + +interface ObservableSortStore { + sortOrder: SortInstruction[] | undefined; +} export class QueryParamsController implements ReactiveController { private readonly _query: DatasourceController; - private readonly _filters: CustomFilterHost; - private readonly _sort: SortStoreHost; + private readonly _filtersHost: ObservableFilterStore; + private readonly _sortHost: ObservableSortStore; constructor( host: ReactiveControllerHost, query: DatasourceController, - filters: CustomFilterHost, - sort: SortStoreHost + filters: ObservableFilterStore, + sort: ObservableSortStore ) { host.addController(this); this._query = query; - this._filters = filters; - this._sort = sort; - - makeAutoObservable(this, { setup: false }); - } - - private get _derivedSortOrder(): ListValue["sortOrder"] { - return this._sort.sortOrder; - } - - private get _derivedFilter(): FilterCondition { - return compactArray(this._filters.conditions); + this._filtersHost = filters; + this._sortHost = sort; } setup(): () => void { const [add, disposeAll] = disposeBatch(); add( reaction( - () => this._derivedSortOrder, + () => this._sortHost.sortOrder, sortOrder => this._query.setSortOrder(sortOrder), { fireImmediately: true } ) ); add( reaction( - () => this._derivedFilter, + () => this._filtersHost.filter, filter => this._query.setFilter(filter), { fireImmediately: true } ) @@ -55,11 +51,4 @@ export class QueryParamsController implements ReactiveController { return disposeAll; } - - unzipFilter(filter?: FilterCondition): Array { - if (!filter || !isAnd(filter)) { - return []; - } - return fromCompactArray(filter); - } } diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index 209d7dd01b..59417e1e73 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -1,4 +1,5 @@ import { createContextWithStub, FilterAPI } from "@mendix/widget-plugin-filtering/context"; +import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter"; import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { PaginationController } from "@mendix/widget-plugin-grid/query/PaginationController"; @@ -67,16 +68,17 @@ export class GalleryStore extends BaseControllerHost { this._filtersHost = new CustomFilterHost(); + const combinedFilter = new CombinedFilter(this, { stableKey: spec.name, inputs: [this._filtersHost] }); + this._sortHost = new SortStoreHost({ initSort: spec.gate.props.datasource.sortOrder }); - const paramCtrl = new QueryParamsController(this, this._query, this._filtersHost, this._sortHost); + new QueryParamsController(this, this._query, combinedFilter, this._sortHost); this.filterAPI = createContextWithStub({ filterObserver: this._filtersHost, - parentChannelName: this.id, - sharedInitFilter: paramCtrl.unzipFilter(spec.gate.props.datasource.filter) + parentChannelName: this.id }); this.sortAPI = { @@ -93,6 +95,8 @@ export class GalleryStore extends BaseControllerHost { if (useStorage) { this.initPersistentStorage(spec, spec.gate); } + + combinedFilter.hydrate(spec.gate.props.datasource.filter); } initPersistentStorage(props: StaticProps, gate: GalleryPropsGate): void { diff --git a/packages/shared/filter-commons/src/__tests__/condition-utils.spec.ts b/packages/shared/filter-commons/src/__tests__/condition-utils.spec.ts index 39e062ec82..70e06443ca 100644 --- a/packages/shared/filter-commons/src/__tests__/condition-utils.spec.ts +++ b/packages/shared/filter-commons/src/__tests__/condition-utils.spec.ts @@ -1,50 +1,415 @@ jest.mock("mendix/filters/builders"); -import { AndCondition } from "mendix/filters"; +import { AndCondition, FilterCondition } from "mendix/filters"; import { equals, literal } from "mendix/filters/builders"; -import { compactArray, fromCompactArray, tag } from "../condition-utils"; +import { reduceArray, reduceMap, restoreArray, restoreMap, tag } from "../condition-utils"; describe("condition-utils", () => { - describe("compactArray", () => { - it("returns 'tag' condition for zero array", () => { - const result = compactArray([]); - expect(result).toMatchObject({ - name: "!=", - type: "function", - arg1: { value: "[0,[]]", valueType: "String" } + describe("reduceMap", () => { + it("returns undefined condition and correct metadata for map with undefined values", () => { + const input = { x: undefined, y: undefined }; + const [condition, metadata] = reduceMap(input); + + expect(condition).toBeUndefined(); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual({ + length: 0, + keys: ["x", "y"] + }); + }); + + it("returns single condition and correct metadata for map with one condition", () => { + const tagCondition = tag("test"); + const input = { a: tagCondition, b: undefined, c: undefined }; + const [condition, metadata] = reduceMap(input); + + expect(condition).toBe(tagCondition); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual({ + length: 1, + keys: ["a", "b", "c"], + 0: "a" + }); + }); + + it("returns 'and' condition and correct metadata for map with multiple conditions", () => { + const tagCondition1 = tag("test1"); + const tagCondition2 = tag("test2"); + const input = { x: tagCondition1, y: undefined, z: tagCondition2, w: undefined }; + const [condition, metadata] = reduceMap(input); + + expect(condition).toMatchObject({ name: "and", type: "function" }); + expect((condition as AndCondition).args).toHaveLength(2); + expect((condition as AndCondition).args[0]).toBe(tagCondition1); + expect((condition as AndCondition).args[1]).toBe(tagCondition2); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual({ + length: 2, + keys: ["x", "y", "z", "w"], + 0: "x", + 1: "z" + }); + }); + + it("returns undefined condition for empty map", () => { + const input = {}; + const [condition, metadata] = reduceMap(input); + + expect(condition).toBeUndefined(); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual({ + length: 0, + keys: [] + }); + }); + + it("handles map with mixed condition types", () => { + const tagCondition = tag("tag-test"); + const equalsCondition = equals(literal("field"), literal("value")); + const input = { + tag: tagCondition, + equals: equalsCondition, + empty: undefined, + another: undefined + }; + const [condition, metadata] = reduceMap(input); + + expect(condition).toMatchObject({ name: "and", type: "function" }); + expect((condition as AndCondition).args).toHaveLength(2); + expect((condition as AndCondition).args[0]).toBe(tagCondition); + expect((condition as AndCondition).args[1]).toBe(equalsCondition); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual({ + length: 2, + keys: ["tag", "equals", "empty", "another"], + 0: "tag", + 1: "equals" + }); + }); + }); + + describe("restoreMap", () => { + it("restores map with undefined values from undefined condition", () => { + const originalInput = { x: undefined, y: undefined }; + const [condition, metadata] = reduceMap(originalInput); + + const restored = restoreMap(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores map with single condition", () => { + const tagCondition = tag("test"); + const originalInput = { a: tagCondition, b: undefined, c: undefined }; + const [condition, metadata] = reduceMap(originalInput); + + const restored = restoreMap(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores map with multiple conditions", () => { + const tagCondition1 = tag("test1"); + const tagCondition2 = tag("test2"); + const originalInput = { x: tagCondition1, y: undefined, z: tagCondition2, w: undefined }; + const [condition, metadata] = reduceMap(originalInput); + + const restored = restoreMap(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores empty map", () => { + const originalInput = {}; + const [condition, metadata] = reduceMap(originalInput); + + const restored = restoreMap(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores map with mixed condition types", () => { + const tagCondition = tag("tag-test"); + const equalsCondition = equals(literal("field"), literal("value")); + const originalInput = { + tag: tagCondition, + equals: equalsCondition, + empty: undefined, + another: undefined + }; + const [condition, metadata] = reduceMap(originalInput); + + const restored = restoreMap(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("handles manual metadata for single condition case", () => { + const tagCondition = tag("manual-test"); + const metadata = JSON.stringify({ + length: 1, + keys: ["first", "second", "third"], + 0: "second" + }); + + const restored = restoreMap(tagCondition, metadata); + + expect(restored).toEqual({ + first: undefined, + second: tagCondition, + third: undefined }); }); - it("returns 'tag' condition for array of undefined", () => { - const result = compactArray([undefined, undefined, undefined]); - expect(result).toMatchObject({ - name: "!=", + it("handles manual metadata for multiple conditions case", () => { + const tagCondition1 = tag("manual1"); + const tagCondition2 = tag("manual2"); + const andCondition = { + name: "and", type: "function", - arg1: { value: "[3,[]]", valueType: "String" } + args: [tagCondition1, tagCondition2] + } as AndCondition; + const metadata = JSON.stringify({ + length: 2, + keys: ["a", "b", "c", "d"], + 0: "a", + 1: "c" + }); + + const restored = restoreMap(andCondition, metadata); + + expect(restored).toEqual({ + a: tagCondition1, + b: undefined, + c: tagCondition2, + d: undefined + }); + }); + + it("handles edge case with undefined condition and non-zero length metadata", () => { + const metadata = JSON.stringify({ + length: 1, + keys: ["test"], + 0: "test" + }); + + const restored = restoreMap(undefined, metadata); + + expect(restored).toEqual({ + test: undefined }); }); - it("returns 'and' condition with 3 args", () => { - const result = compactArray([tag("0"), undefined, tag("2")]); - expect(result).toMatchObject({ name: "and", type: "function" }); - expect((result as AndCondition).args).toHaveLength(3); + it("handles edge case with non-and condition but multiple length metadata", () => { + const tagCondition = tag("single"); + const metadata = JSON.stringify({ + length: 2, + keys: ["a", "b"], + 0: "a", + 1: "b" + }); + + const restored = restoreMap(tagCondition, metadata); + + expect(restored).toEqual({ + a: undefined, + b: undefined + }); }); }); - describe("fromCompactArray", () => { - it("unpack condition created with compactArray", () => { - const input = [ - equals(literal("1"), literal("1")), - undefined, - equals(literal("foo"), literal("bar")), - undefined, - undefined, - equals(literal(new Date("2024-09-17T14:00:00.000Z")), literal(new Date("2024-09-17T14:00:00.000Z"))) - ]; - const cond = compactArray(input); - expect(cond).toBeDefined(); - const result = fromCompactArray(cond!); - expect(result).toEqual(input); + describe("reduceArray", () => { + it("returns undefined condition and correct metadata for array with undefined values", () => { + const input = [undefined, undefined, undefined]; + const [condition, metadata] = reduceArray(input); + + expect(condition).toBeUndefined(); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([3, []]); + }); + + it("returns single condition and correct metadata for array with one condition", () => { + const tagCondition = tag("test"); + const input = [undefined, tagCondition, undefined]; + const [condition, metadata] = reduceArray(input); + + expect(condition).toBe(tagCondition); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([3, [1]]); + }); + + it("returns 'and' condition and correct metadata for array with multiple conditions", () => { + const tagCondition1 = tag("test1"); + const tagCondition2 = tag("test2"); + const input = [tagCondition1, undefined, tagCondition2, undefined]; + const [condition, metadata] = reduceArray(input); + + expect(condition).toMatchObject({ name: "and", type: "function" }); + expect((condition as AndCondition).args).toHaveLength(2); + expect((condition as AndCondition).args[0]).toBe(tagCondition1); + expect((condition as AndCondition).args[1]).toBe(tagCondition2); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([4, [0, 2]]); + }); + + it("returns undefined condition for empty array", () => { + const input: Array = []; + const [condition, metadata] = reduceArray(input); + + expect(condition).toBeUndefined(); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([0, []]); + }); + + it("handles array with mixed condition types", () => { + const tagCondition = tag("tag-test"); + const equalsCondition = equals(literal("field"), literal("value")); + const input = [tagCondition, undefined, equalsCondition, undefined, undefined]; + const [condition, metadata] = reduceArray(input); + + expect(condition).toMatchObject({ name: "and", type: "function" }); + expect((condition as AndCondition).args).toHaveLength(2); + expect((condition as AndCondition).args[0]).toBe(tagCondition); + expect((condition as AndCondition).args[1]).toBe(equalsCondition); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([5, [0, 2]]); + }); + + it("handles array with single condition at beginning", () => { + const tagCondition = tag("first"); + const input = [tagCondition, undefined, undefined]; + const [condition, metadata] = reduceArray(input); + + expect(condition).toBe(tagCondition); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([3, [0]]); + }); + + it("handles array with single condition at end", () => { + const tagCondition = tag("last"); + const input = [undefined, undefined, tagCondition]; + const [condition, metadata] = reduceArray(input); + + expect(condition).toBe(tagCondition); + + const parsedMeta = JSON.parse(metadata); + expect(parsedMeta).toEqual([3, [2]]); + }); + }); + + describe("restoreArray", () => { + it("restores array with undefined values from undefined condition", () => { + const originalInput = [undefined, undefined, undefined]; + const [condition, metadata] = reduceArray(originalInput); + + const restored = restoreArray(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores array with single condition", () => { + const tagCondition = tag("test"); + const originalInput = [undefined, tagCondition, undefined]; + const [condition, metadata] = reduceArray(originalInput); + + const restored = restoreArray(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores array with multiple conditions", () => { + const tagCondition1 = tag("test1"); + const tagCondition2 = tag("test2"); + const originalInput = [tagCondition1, undefined, tagCondition2, undefined]; + const [condition, metadata] = reduceArray(originalInput); + + const restored = restoreArray(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores empty array", () => { + const originalInput: Array = []; + const [condition, metadata] = reduceArray(originalInput); + + const restored = restoreArray(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("restores array with mixed condition types", () => { + const tagCondition = tag("tag-test"); + const equalsCondition = equals(literal("field"), literal("value")); + const originalInput = [tagCondition, undefined, equalsCondition, undefined, undefined]; + const [condition, metadata] = reduceArray(originalInput); + + const restored = restoreArray(condition, metadata); + + expect(restored).toEqual(originalInput); + }); + + it("handles manual metadata for single condition case", () => { + const tagCondition = tag("manual-test"); + const metadata = JSON.stringify([4, [2]]); + + const restored = restoreArray(tagCondition, metadata); + + expect(restored).toEqual([undefined, undefined, tagCondition, undefined]); + }); + + it("handles manual metadata for multiple conditions case", () => { + const tagCondition1 = tag("manual1"); + const tagCondition2 = tag("manual2"); + const andCondition = { + name: "and", + type: "function", + args: [tagCondition1, tagCondition2] + } as AndCondition; + const metadata = JSON.stringify([5, [1, 3]]); + + const restored = restoreArray(andCondition, metadata); + + expect(restored).toEqual([undefined, tagCondition1, undefined, tagCondition2, undefined]); + }); + + it("handles edge case with undefined condition and non-empty indexes", () => { + const metadata = JSON.stringify([3, [1]]); + + const restored = restoreArray(undefined, metadata); + + expect(restored).toEqual([undefined, undefined, undefined]); + }); + + it("handles edge case with non-and condition but multiple indexes", () => { + const tagCondition = tag("single"); + const metadata = JSON.stringify([4, [0, 2]]); + + const restored = restoreArray(tagCondition, metadata); + + expect(restored).toEqual([undefined, undefined, undefined, undefined]); + }); + + it("round-trip test with complex array", () => { + const tag1 = tag("one"); + const tag2 = tag("two"); + const eq1 = equals(literal("a"), literal("b")); + const originalInput = [tag1, undefined, eq1, undefined, tag2, undefined, undefined]; + + const [condition, metadata] = reduceArray(originalInput); + const restored = restoreArray(condition, metadata); + + expect(restored).toEqual(originalInput); }); }); }); diff --git a/packages/shared/filter-commons/src/condition-utils.ts b/packages/shared/filter-commons/src/condition-utils.ts index 976da416b5..107b10012f 100644 --- a/packages/shared/filter-commons/src/condition-utils.ts +++ b/packages/shared/filter-commons/src/condition-utils.ts @@ -55,6 +55,7 @@ interface TagCond { readonly arg2: TagMarker; } +/** @deprecated use for unit tests only */ export function tag(name: string): TagCond { return notEqual(literal(name), literal(MARKER)) as TagCond; } @@ -72,60 +73,105 @@ export function isTag(cond: FilterCondition): cond is TagCond { type ArrayMeta = readonly [len: number, indexes: number[]]; -function arrayTag(meta: ArrayMeta): string { - return JSON.stringify(meta); -} - -function fromArrayTag(tag: string): ArrayMeta | undefined { - let len: ArrayMeta[0]; - let indexes: ArrayMeta[1]; - try { - [len, indexes] = JSON.parse(tag); - } catch { - return undefined; - } - if (typeof len !== "number" || !Array.isArray(indexes) || !indexes.every(x => typeof x === "number")) { - return undefined; - } - return [len, indexes]; -} - function shrink(array: Array): [indexes: number[], items: T[]] { return [array.flatMap((x, i) => (x === undefined ? [] : [i])), array.filter((x): x is T => x !== undefined)]; } -export function compactArray(input: Array): FilterCondition { +export function reduceArray( + input: Array +): [cond: FilterCondition | undefined, meta: string] { const [indexes, items] = shrink(input); - const metaTag = tag(arrayTag([input.length, indexes] as const)); + const meta = JSON.stringify([input.length, indexes]); + + switch (items.length) { + case 0: + return [undefined, meta]; + case 1: + return [items[0], meta]; + default: + return [and(...items), meta]; + } +} - if (items.length === 0) { - return metaTag; +export function restoreArray(cond: FilterCondition | undefined, meta: string): Array { + const [length, indexes] = JSON.parse(meta) as ArrayMeta; + const arr: Array = Array(length).fill(undefined); + + if (indexes.length === 0) { + return arr; + } + if (indexes.length === 1) { + arr[indexes[0]] = cond; + return arr; + } + if (cond && isAnd(cond)) { + cond.args.forEach((c, i) => { + arr[indexes[i]] = c; + }); + return arr; } - return and(metaTag, ...items); + return arr; } -export function fromCompactArray(cond: FilterCondition): Array { - const tag = isAnd(cond) ? cond.args[0] : cond; +type ReduceMapMeta = { + [index: number]: string; + length: number; + keys: string[]; +}; + +export function reduceMap(input: Record): [FilterCondition | undefined, string] { + const meta: ReduceMapMeta = { length: 0, keys: [] }; + const conditions: FilterCondition[] = []; + for (const [key, value] of Object.entries(input)) { + meta.keys.push(key); + if (value !== undefined) { + meta[conditions.length] = key; + conditions.push(value); + } + } + meta.length = conditions.length; - const arrayMeta = isTag(tag) ? fromArrayTag(tag.arg1.value) : undefined; + const metaJson = JSON.stringify(meta); - if (!arrayMeta) { - return []; + switch (conditions.length) { + case 0: + return [undefined, metaJson]; + case 1: + return [conditions[0], metaJson]; + default: + return [and(...conditions), metaJson]; } +} - const [length, indexes] = arrayMeta; - const arr: Array = Array(length).fill(undefined); +export function restoreMap( + cond: FilterCondition | undefined, + metaJson: string +): Record { + const meta = JSON.parse(metaJson) as ReduceMapMeta; + const result: Record = {}; - if (!isAnd(cond)) { - return arr; + for (const key of meta.keys) { + result[key] = undefined; } - cond.args.slice(1).forEach((cond, i) => { - arr[indexes[i]] = cond; - }); + if (meta.length === 0) { + return result; + } - return arr; + if (meta.length === 1 && meta[0]) { + result[meta[0]] = cond; + return result; + } + + if (cond && isAnd(cond)) { + cond.args.forEach((c, i) => { + result[meta[i]] = c; + }); + return result; + } + + return result; } export function inputStateFromCond( diff --git a/packages/shared/widget-plugin-filtering/src/context.ts b/packages/shared/widget-plugin-filtering/src/context.ts index 382df8a092..79cdb442d0 100644 --- a/packages/shared/widget-plugin-filtering/src/context.ts +++ b/packages/shared/widget-plugin-filtering/src/context.ts @@ -1,5 +1,4 @@ import { EnumFilterStore } from "@mendix/widget-plugin-dropdown-filter/stores/EnumFilterStore"; -import { FilterCondition } from "mendix/filters"; import { Context, createContext, useContext } from "react"; import { APIError, ENOCONTEXT } from "./errors"; import { Result, error, value } from "./result-meta"; @@ -11,7 +10,6 @@ export interface FilterAPI { parentChannelName: string; provider: Result; filterObserver: ObservableFilterHost; - sharedInitFilter: Array; } export type FilterStore = InputFilterInterface | EnumFilterStore; @@ -59,13 +57,11 @@ export const useFilterContextValue = useFilterAPI; export function createContextWithStub(options: { filterObserver: ObservableFilterHost; parentChannelName: string; - sharedInitFilter: Array; }): FilterAPI { return { version: 3, parentChannelName: options.parentChannelName, provider: value(PROVIDER_STUB), - filterObserver: options.filterObserver, - sharedInitFilter: options.sharedInitFilter + filterObserver: options.filterObserver }; } diff --git a/packages/shared/widget-plugin-filtering/src/custom-filter-api/BaseStoreProvider.ts b/packages/shared/widget-plugin-filtering/src/custom-filter-api/BaseStoreProvider.ts index 41299b12e1..cafbd40ecb 100644 --- a/packages/shared/widget-plugin-filtering/src/custom-filter-api/BaseStoreProvider.ts +++ b/packages/shared/widget-plugin-filtering/src/custom-filter-api/BaseStoreProvider.ts @@ -1,7 +1,5 @@ -import { isAnd, isTag } from "@mendix/filter-commons/condition-utils"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { ISetupable } from "@mendix/widget-plugin-mobx-kit/setupable"; -import { FilterCondition } from "mendix/filters"; import { FilterAPI } from "../context"; import { Filter } from "../typings/ObservableFilterHost"; @@ -10,18 +8,6 @@ export abstract class BaseStoreProvider implements ISetupable protected abstract filterAPI: FilterAPI; abstract readonly dataKey: string; - protected findInitFilter(conditions: Array, key: string): FilterCondition | null { - for (const cond of conditions) { - if (cond && isAnd(cond)) { - const [tag, initFilter] = cond.args; - if (isTag(tag) && tag.arg1.value === key) { - return initFilter; - } - } - } - return null; - } - setup(): () => void { const [add, disposeAll] = disposeBatch(); this.filterAPI.filterObserver.observe(this.dataKey, this._store); diff --git a/packages/shared/widget-plugin-filtering/src/custom-filter-api/DateStoreProvider.ts b/packages/shared/widget-plugin-filtering/src/custom-filter-api/DateStoreProvider.ts index 65cfe580d5..fedbb94dd2 100644 --- a/packages/shared/widget-plugin-filtering/src/custom-filter-api/DateStoreProvider.ts +++ b/packages/shared/widget-plugin-filtering/src/custom-filter-api/DateStoreProvider.ts @@ -13,10 +13,7 @@ export class DateStoreProvider extends BaseStoreProvider { super(); this.filterAPI = filterAPI; this.dataKey = spec.dataKey; - this._store = new DateInputFilterStore( - spec.attributes, - this.findInitFilter(filterAPI.sharedInitFilter, this.dataKey) - ); + this._store = new DateInputFilterStore(spec.attributes, null); } get store(): Date_InputFilterInterface { diff --git a/packages/shared/widget-plugin-filtering/src/custom-filter-api/EnumStoreProvider.ts b/packages/shared/widget-plugin-filtering/src/custom-filter-api/EnumStoreProvider.ts index c72ad7167b..edf5fe97c6 100644 --- a/packages/shared/widget-plugin-filtering/src/custom-filter-api/EnumStoreProvider.ts +++ b/packages/shared/widget-plugin-filtering/src/custom-filter-api/EnumStoreProvider.ts @@ -12,10 +12,7 @@ export class EnumStoreProvider extends BaseStoreProvider { super(); this.filterAPI = filterAPI; this.dataKey = spec.dataKey; - this._store = new EnumFilterStore( - spec.attributes, - this.findInitFilter(filterAPI.sharedInitFilter, this.dataKey) - ); + this._store = new EnumFilterStore(spec.attributes, null); } get store(): EnumFilterStore { diff --git a/packages/shared/widget-plugin-filtering/src/custom-filter-api/NumberStoreProvider.ts b/packages/shared/widget-plugin-filtering/src/custom-filter-api/NumberStoreProvider.ts index da7f77e639..5b3639f346 100644 --- a/packages/shared/widget-plugin-filtering/src/custom-filter-api/NumberStoreProvider.ts +++ b/packages/shared/widget-plugin-filtering/src/custom-filter-api/NumberStoreProvider.ts @@ -13,10 +13,7 @@ export class NumberStoreProvider extends BaseStoreProvider; + +export class CombinedFilter implements ReactiveController { + private _inputs: ObservableInput[]; + readonly stableKey: string; + readonly ownMetaKey = "CombinedFilter"; + + constructor(host: ReactiveControllerHost, spec: { stableKey: string; inputs: ObservableInput[] }) { + host.addController(this); + this._inputs = spec.inputs; + this.stableKey = spec.stableKey; + + makeObservable(this, { + filter: computed, + filterWithBag: computed, + hydrate: action + }); + } + + storageKey(hash: string): string { + return `${this.stableKey}@${hash}`; + } + + readMetaFromStorage(key: string): MetaBag | null { + const item = sessionStorage.getItem(key); + if (!item) { + return null; + } + try { + return JSON.parse(item) as MetaBag; + } catch (e) { + console.error(`CombinedFilter.readFilterMeta: Error parsing meta for key ${key}`, e); + return null; + } + } + + clearFilterMeta(hash: string): void { + sessionStorage.removeItem(this.storageKey(hash)); + } + + filterHash(filter: FilterCondition): string { + return fnv1aHash(JSON.stringify(filter)).toString(); + } + + restoreMeta(filter: FilterCondition): MetaBag | null { + const hash = this.filterHash(filter); + const key = this.storageKey(hash); + return this.readMetaFromStorage(key); + } + + hydrate(filter: FilterCondition | undefined): void { + if (!filter) { + return; + } + + const meta = this.restoreMeta(filter); + if (!meta) { + return; + } + + const conditions = restoreArray(filter, meta[this.ownMetaKey]); + if (conditions.length !== this._inputs.length) { + console.error( + `CombinedFilter.hydrate: Number of conditions (${conditions.length}) does not match number of inputs (${this._inputs.length})` + ); + return; + } + + for (let i = 0; i < this._inputs.length; i++) { + const input = this._inputs[i]; + const condWithMeta: ConditionWithMeta = { + cond: conditions[i], + meta: meta[input.metaKey] + }; + + input.hydrate(condWithMeta); + } + } + + get filter(): FilterCondition | undefined { + return this.filterWithBag.filter; + } + + get filterWithBag(): { + filter: FilterCondition | undefined; + bag: MetaBag; + hash: string | null; + } { + const bag: MetaBag = {}; + const conditions: Array = []; + + for (const { condWithMeta: data, metaKey } of this._inputs) { + bag[metaKey] = data.meta; + conditions.push(data.cond); + } + + const [filter, meta] = reduceArray(conditions); + bag[this.ownMetaKey] = meta; + + return { filter, bag, hash: filter ? this.filterHash(filter) : null }; + } + + saveFilterMeta(hash: string | null, bag: MetaBag): void { + if (!hash) { + return; + } + + sessionStorage.setItem(this.storageKey(hash), JSON.stringify(bag)); + } + + setup(): () => void { + const [add, disposeAll] = disposeBatch(); + + add( + reaction( + () => this.filterWithBag, + (next, prev) => { + if (prev && prev.hash) { + this.clearFilterMeta(prev.hash); + } + if (next.hash) { + this.saveFilterMeta(next.hash, next.bag); + } + } + ) + ); + + return disposeAll; + } +} diff --git a/packages/shared/widget-plugin-filtering/src/stores/generic/CustomFilterHost.ts b/packages/shared/widget-plugin-filtering/src/stores/generic/CustomFilterHost.ts index 8e461dfbae..3e850e869d 100644 --- a/packages/shared/widget-plugin-filtering/src/stores/generic/CustomFilterHost.ts +++ b/packages/shared/widget-plugin-filtering/src/stores/generic/CustomFilterHost.ts @@ -1,16 +1,21 @@ -import { tag } from "@mendix/filter-commons/condition-utils"; +import { reduceMap, restoreMap } from "@mendix/filter-commons/condition-utils"; import { FilterData, FiltersSettingsMap, PlainJs, Serializable } from "@mendix/filter-commons/typings/settings"; import { FilterCondition } from "mendix/filters"; -import { and } from "mendix/filters/builders"; -import { autorun, makeAutoObservable } from "mobx"; +import { action, autorun, makeAutoObservable } from "mobx"; +import { ConditionWithMeta } from "../../typings/ConditionWithMeta"; import { Filter, ObservableFilterHost } from "../../typings/ObservableFilterHost"; export class CustomFilterHost implements ObservableFilterHost, Serializable { private filters: Map void]> = new Map(); private settingsBuffer: FiltersSettingsMap = new Map(); + private _state: Map = new Map(); + + readonly metaKey = "CustomFilterHost"; constructor() { - makeAutoObservable(this); + makeAutoObservable(this, { + hydrate: action + }); } get settings(): FiltersSettingsMap { @@ -21,22 +26,27 @@ export class CustomFilterHost implements ObservableFilterHost, Serializable { this.settingsBuffer = data; } - get conditions(): Array { - return [...this.filters].map(([key, [{ condition }]]) => { - return condition ? and(tag(key), condition) : undefined; - }); - } - observe(key: string, filter: Filter): void { this.unobserve(key); - const clear = autorun(() => { + const clearSettingsSync = autorun(() => { if (this.settingsBuffer.has(key)) { filter.fromJSON(this.settingsBuffer.get(key)); } }); + const clearStateSync = autorun(() => { + this._state.set(key, filter.condition); + }); + const skipInit = this.settingsBuffer.has(key); + const initCond = this._state.get(key); + if (!skipInit && initCond) { + filter.fromViewState(initCond); + } + const dispose = (): void => { - clear(); + clearSettingsSync(); + clearStateSync(); this.filters.delete(key); + this._state.delete(key); }; this.filters.set(key, [filter, dispose]); } @@ -58,4 +68,24 @@ export class CustomFilterHost implements ObservableFilterHost, Serializable { this.settings = new Map(data as Array<[string, FilterData]>); } + + get condWithMeta(): ConditionWithMeta { + const conditions: Record = {}; + + for (const [key, [filter]] of this.filters) { + conditions[key] = filter.condition; + } + + const [cond, meta] = reduceMap(conditions); + + return { + cond, + meta + }; + } + + hydrate({ cond, meta }: ConditionWithMeta): void { + const map = restoreMap(cond, meta); + this._state = new Map(Object.entries(map)); + } } diff --git a/packages/shared/widget-plugin-filtering/src/typings/ConditionWithMeta.ts b/packages/shared/widget-plugin-filtering/src/typings/ConditionWithMeta.ts new file mode 100644 index 0000000000..b4529861e9 --- /dev/null +++ b/packages/shared/widget-plugin-filtering/src/typings/ConditionWithMeta.ts @@ -0,0 +1,6 @@ +import { FilterCondition } from "mendix/filters"; + +export type ConditionWithMeta = { + cond: FilterCondition | undefined; + meta: string; +}; diff --git a/packages/shared/widget-plugin-filtering/src/typings/ObservableFilterHost.ts b/packages/shared/widget-plugin-filtering/src/typings/ObservableFilterHost.ts index 0a06416b37..66a8a70c6d 100644 --- a/packages/shared/widget-plugin-filtering/src/typings/ObservableFilterHost.ts +++ b/packages/shared/widget-plugin-filtering/src/typings/ObservableFilterHost.ts @@ -4,12 +4,12 @@ import { FilterCondition } from "mendix/filters"; export interface Filter { toJSON(): FilterData; fromJSON(data: FilterData): void; + fromViewState(data: FilterCondition): void; condition: FilterCondition | undefined; setup?: () => void | void; } export interface ObservableFilterHost { - conditions: Array; get settings(): FiltersSettingsMap; set settings(settings: FiltersSettingsMap); observe(key: string, filter: Filter): void; diff --git a/packages/shared/widget-plugin-filtering/src/utils/fnv-1a-hash.ts b/packages/shared/widget-plugin-filtering/src/utils/fnv-1a-hash.ts new file mode 100644 index 0000000000..f086fa8d82 --- /dev/null +++ b/packages/shared/widget-plugin-filtering/src/utils/fnv-1a-hash.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-bitwise */ + +/** + * Generates 32 bit FNV-1a hash from the given string. + * As explained here: http://isthe.com/chongo/tech/comp/fnv/ + * + * @param s {string} String to generate hash from. + * @param [h] {number} FNV-1a hash generation init value. + * @returns {number} The result integer hash. + */ +export function fnv1aHash(s: string, h: number = 0x811c9dc5): number { + const l = s.length; + + for (let i = 0; i < l; i++) { + h ^= s.charCodeAt(i); + h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); + } + + return h >>> 0; +} diff --git a/packages/shared/widget-plugin-grid/src/utils/fnv-1a-hash.ts b/packages/shared/widget-plugin-grid/src/utils/fnv-1a-hash.ts new file mode 100644 index 0000000000..f086fa8d82 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/utils/fnv-1a-hash.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-bitwise */ + +/** + * Generates 32 bit FNV-1a hash from the given string. + * As explained here: http://isthe.com/chongo/tech/comp/fnv/ + * + * @param s {string} String to generate hash from. + * @param [h] {number} FNV-1a hash generation init value. + * @returns {number} The result integer hash. + */ +export function fnv1aHash(s: string, h: number = 0x811c9dc5): number { + const l = s.length; + + for (let i = 0; i < l; i++) { + h ^= s.charCodeAt(i); + h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); + } + + return h >>> 0; +}