From dffb3d491570eeedd7fbc491d8bc532ab2072f52 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:38:11 +0100 Subject: [PATCH 1/7] refactor: extract logic to grid plugin --- .../src/core/Datasource.service.ts | 4 +- .../__tests__/emptyStateWidgetsAtom.spec.ts | 80 ++++++++++++ .../core/__tests__/hasMoreItemsAtom.spec.ts | 25 ++++ .../__tests__/isAllItemsPresentAtom.spec.ts | 68 ++++++++++ .../core/__tests__/isAllItemsSelected.spec.ts | 122 ++++++++++++++++++ .../isCurrentPageSelectedAtom.spec.ts | 77 +++++++++++ .../src/core/__tests__/itemCountAtom.spec.ts | 43 ++++++ .../src/core/__tests__/limitAtom.spec.ts | 23 ++++ .../src/core/__tests__/offsetAtom.spec.ts | 23 ++++ .../core/__tests__/selectedCountMulti.spec.ts | 36 ++++++ .../src/core/__tests__/totalCountAtom.spec.ts | 43 ++++++ .../src/core/models/datasource.model.ts | 65 ++++++++++ .../src/core/models/empty-state.model.ts | 20 +++ .../src/core/models/selection.model.ts | 70 ++++++++++ .../src/select-all/SelectAllBar.store.ts | 37 ++++++ .../src/select-all/select-all.feature.ts | 30 +++++ .../src/select-all/select-all.model.ts | 114 ++++++++++++++++ .../src/utils/mobx-test-setup.ts | 3 + 18 files changed, 881 insertions(+), 2 deletions(-) create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/emptyStateWidgetsAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/hasMoreItemsAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsPresentAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsSelected.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/isCurrentPageSelectedAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/itemCountAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/limitAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/offsetAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/__tests__/totalCountAtom.spec.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/models/datasource.model.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/models/empty-state.model.ts create mode 100644 packages/shared/widget-plugin-grid/src/core/models/selection.model.ts create mode 100644 packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts create mode 100644 packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts create mode 100644 packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts create mode 100644 packages/shared/widget-plugin-grid/src/utils/mobx-test-setup.ts diff --git a/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts b/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts index 8bd399e4ca..052d963dde 100644 --- a/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts +++ b/packages/shared/widget-plugin-grid/src/core/Datasource.service.ts @@ -124,12 +124,12 @@ export class DatasourceService implements SetupComponent, QueryService { // Subscribe to items to reschedule timer on items change // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.items; - clearInterval(timerId); + clearTimeout(timerId); timerId = window.setTimeout(() => this.backgroundRefresh(), this.refreshIntervalMs); }); add(() => { clearAutorun(); - clearInterval(timerId); + clearTimeout(timerId); }); } diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/emptyStateWidgetsAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/emptyStateWidgetsAtom.spec.ts new file mode 100644 index 0000000000..3ac9b2c014 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/emptyStateWidgetsAtom.spec.ts @@ -0,0 +1,80 @@ +import { DerivedGate, GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { autorun, computed, observable } from "mobx"; +import { ReactNode } from "react"; +import "../../utils/mobx-test-setup.js"; +import { emptyStateWidgetsAtom } from "../models/empty-state.model.js"; + +describe("emptyStateWidgetsAtom", () => { + it("returns null when emptyPlaceholder is undefined", () => { + const gate = new DerivedGate({ props: { emptyPlaceholder: undefined } }); + const itemsCount = computed(() => 0); + const atom = emptyStateWidgetsAtom(gate, itemsCount); + + expect(atom.get()).toBe(null); + }); + + it("returns null when items count is greater than 0", () => { + const gate = new DerivedGate({ props: { emptyPlaceholder: "Empty state message" } }); + const itemsCount = computed(() => 5); + const atom = emptyStateWidgetsAtom(gate, itemsCount); + + expect(atom.get()).toBe(null); + }); + + it("returns null when items count is -1 (loading state)", () => { + const gate = new DerivedGate({ props: { emptyPlaceholder: "Empty state message" } }); + const itemsCount = computed(() => -1); + const atom = emptyStateWidgetsAtom(gate, itemsCount); + + expect(atom.get()).toBe(null); + }); + + it("returns emptyPlaceholder when both emptyPlaceholder is defined and itemsCount is exactly 0", () => { + const message = "Empty state message"; + const gate = new DerivedGate({ props: { emptyPlaceholder: message } }); + const itemsCount = computed(() => 0); + const atom = emptyStateWidgetsAtom(gate, itemsCount); + + expect(atom.get()).toBe(message); + }); + + describe("reactive behavior", () => { + it("reacts to changes in both emptyPlaceholder and itemsCount", () => { + const gateProvider = new GateProvider({ + emptyPlaceholder: undefined as ReactNode + }); + const itemCountBox = observable.box(5); + const atom = emptyStateWidgetsAtom(gateProvider.gate, itemCountBox); + const values: ReactNode[] = []; + + const dispose = autorun(() => values.push(atom.get())); + + // Initial state: no placeholder, items > 0 → null + expect(values.at(-1)).toBe(null); + + // Add placeholder but items count > 0 → still null + gateProvider.setProps({ emptyPlaceholder: "Empty message" }); + expect(values.at(-1)).toBe(null); + + // Set items count to 0 → should show placeholder + itemCountBox.set(0); + expect(values.at(-1)).toBe("Empty message"); + + // Remove placeholder while count is 0 → null + gateProvider.setProps({ emptyPlaceholder: undefined }); + expect(values.at(-1)).toBe(null); + + // Add different placeholder back with count still 0 → show new placeholder + gateProvider.setProps({ emptyPlaceholder: "No data available" }); + expect(values.at(-1)).toBe("No data available"); + + // Increase count while placeholder exists → null + itemCountBox.set(3); + expect(values.at(-1)).toBe(null); + + expect(values).toEqual([null, "Empty message", null, "No data available", null]); + + dispose(); + }); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/hasMoreItemsAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/hasMoreItemsAtom.spec.ts new file mode 100644 index 0000000000..6c06c75126 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/hasMoreItemsAtom.spec.ts @@ -0,0 +1,25 @@ +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { autorun } from "mobx"; +import { hasMoreItemsAtom } from "../models/datasource.model.js"; + +describe("hasMoreItemsAtom", () => { + it("reacts to datasource hasMoreItems changes", () => { + const gateProvider = new GateProvider<{ datasource: { hasMoreItems?: boolean } }>({ + datasource: { hasMoreItems: undefined } + }); + const atom = hasMoreItemsAtom(gateProvider.gate); + const values: Array = []; + + autorun(() => values.push(atom.get())); + + expect(values.at(0)).toBe(undefined); + + gateProvider.setProps({ datasource: { hasMoreItems: true } }); + gateProvider.setProps({ datasource: { hasMoreItems: false } }); + gateProvider.setProps({ datasource: { hasMoreItems: true } }); + gateProvider.setProps({ datasource: { hasMoreItems: undefined } }); + gateProvider.setProps({ datasource: { hasMoreItems: false } }); + + expect(values).toEqual([undefined, true, false, true, undefined, false]); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsPresentAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsPresentAtom.spec.ts new file mode 100644 index 0000000000..31e8e45bf8 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsPresentAtom.spec.ts @@ -0,0 +1,68 @@ +import { autorun, computed, observable } from "mobx"; +import { isAllItemsPresent, isAllItemsPresentAtom } from "../models/datasource.model.js"; + +import "../../utils/mobx-test-setup.js"; + +describe("isAllItemsPresent", () => { + it("returns true when offset is 0 and hasMoreItems is false", () => { + expect(isAllItemsPresent(0, false)).toBe(true); + }); + + it("returns false when offset is 0 and hasMoreItems is true", () => { + expect(isAllItemsPresent(0, true)).toBe(false); + }); + + it("returns false when offset is 0 and hasMoreItems is undefined", () => { + expect(isAllItemsPresent(0, undefined)).toBe(false); + }); + + it("returns false when offset is greater than 0 and hasMoreItems is false", () => { + expect(isAllItemsPresent(10, false)).toBe(false); + }); + + it("returns false when offset is greater than 0 and hasMoreItems is true", () => { + expect(isAllItemsPresent(10, true)).toBe(false); + }); + + it("returns false when offset is greater than 0 and hasMoreItems is undefined", () => { + expect(isAllItemsPresent(10, undefined)).toBe(false); + }); + + it("returns false when offset is negative and hasMoreItems is false", () => { + expect(isAllItemsPresent(-1, false)).toBe(false); + }); +}); + +describe("isAllItemsPresentAtom", () => { + it("reacts to changes in offset and hasMoreItems", () => { + const offsetState = observable.box(0); + const hasMoreItemsState = observable.box(false); + + const offsetComputed = computed(() => offsetState.get()); + const hasMoreItemsComputed = computed(() => hasMoreItemsState.get()); + + const atom = isAllItemsPresentAtom(offsetComputed, hasMoreItemsComputed); + const values: boolean[] = []; + + autorun(() => values.push(atom.get())); + + expect(values.at(0)).toBe(true); + + hasMoreItemsState.set(true); + expect(atom.get()).toBe(false); + + offsetState.set(10); + expect(atom.get()).toBe(false); + + hasMoreItemsState.set(false); + expect(atom.get()).toBe(false); + + offsetState.set(0); + expect(atom.get()).toBe(true); + + hasMoreItemsState.set(undefined); + expect(atom.get()).toBe(false); + + expect(values).toEqual([true, false, true, false]); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsSelected.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsSelected.spec.ts new file mode 100644 index 0000000000..7d2f3b3033 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/isAllItemsSelected.spec.ts @@ -0,0 +1,122 @@ +import { computed, configure, observable } from "mobx"; +import { isAllItemsSelected, isAllItemsSelectedAtom } from "../models/selection.model.js"; + +describe("isAllItemsSelected", () => { + describe("when selectedCount is -1 (not in multi-selection mode)", () => { + it("returns false regardless of other parameters", () => { + expect(isAllItemsSelected(-1, 10, 100, true)).toBe(false); + expect(isAllItemsSelected(-1, 0, 0, true)).toBe(false); + expect(isAllItemsSelected(-1, 10, 100, false)).toBe(false); + }); + }); + + describe("when totalCount is -1 and isAllItemsPresent is false", () => { + it("returns false even when selectedCount equals itemCount", () => { + expect(isAllItemsSelected(50, 50, -1, false)).toBe(false); + }); + + it("returns false when selectedCount is less than itemCount", () => { + expect(isAllItemsSelected(25, 50, -1, false)).toBe(false); + }); + + it("returns false when selectedCount is greater than itemCount", () => { + expect(isAllItemsSelected(75, 50, -1, false)).toBe(false); + }); + + it("returns false even when both selectedCount and itemCount are 0", () => { + expect(isAllItemsSelected(0, 0, -1, false)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("returns false when selectedCount is 0 and there are items", () => { + expect(isAllItemsSelected(0, 10, 100, true)).toBe(false); + }); + + it("handles case where itemCount exceeds totalCount (data inconsistency)", () => { + expect(isAllItemsSelected(100, 150, 100, true)).toBe(true); + }); + + it("handles negative itemCount edge case", () => { + expect(isAllItemsSelected(5, -1, 0, true)).toBe(false); + }); + + it("handles negative totalCount edge case", () => { + expect(isAllItemsSelected(5, 10, -1, true)).toBe(false); + }); + }); +}); + +describe("isAllItemsSelectedAtom", () => { + configure({ + enforceActions: "never" + }); + + it("returns true when all items are selected based on totalCount", () => { + const selectedCount = computed(() => 100); + const itemCount = computed(() => 50); + const totalCount = computed(() => 100); + const isAllItemsPresent = computed(() => true); + + const atom = isAllItemsSelectedAtom(selectedCount, itemCount, totalCount, isAllItemsPresent); + expect(atom.get()).toBe(true); + }); + + it("returns false when selectedCount is less than totalCount", () => { + const selectedCount = computed(() => 50); + const itemCount = computed(() => 50); + const totalCount = computed(() => 100); + const isAllItemsPresent = computed(() => true); + + const atom = isAllItemsSelectedAtom(selectedCount, itemCount, totalCount, isAllItemsPresent); + expect(atom.get()).toBe(false); + }); + + it("returns true when all items selected with isAllItemsPresent", () => { + const selectedCount = computed(() => 50); + const itemCount = computed(() => 50); + const totalCount = computed(() => 0); + const isAllItemsPresent = computed(() => true); + + const atom = isAllItemsSelectedAtom(selectedCount, itemCount, totalCount, isAllItemsPresent); + expect(atom.get()).toBe(true); + }); + + it("returns false when selectedCount is -1", () => { + const selectedCount = computed(() => -1); + const itemCount = computed(() => 10); + const totalCount = computed(() => 100); + const isAllItemsPresent = computed(() => true); + + const atom = isAllItemsSelectedAtom(selectedCount, itemCount, totalCount, isAllItemsPresent); + expect(atom.get()).toBe(false); + }); + + it("updates reactively when selectedCount changes", () => { + const selectedCountBox = observable.box(50); + const itemCount = computed(() => 50); + const totalCount = computed(() => 100); + const isAllItemsPresent = computed(() => true); + + const atom = isAllItemsSelectedAtom(selectedCountBox, itemCount, totalCount, isAllItemsPresent); + + expect(atom.get()).toBe(false); + + selectedCountBox.set(100); + expect(atom.get()).toBe(true); + }); + + it("updates reactively when totalCount changes", () => { + const totalCountBox = observable.box(100); + const selectedCount = computed(() => 50); + const itemCount = computed(() => 50); + const isAllItemsPresent = computed(() => true); + + const atom = isAllItemsSelectedAtom(selectedCount, itemCount, totalCountBox, isAllItemsPresent); + + expect(atom.get()).toBe(false); + + totalCountBox.set(50); + expect(atom.get()).toBe(true); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/isCurrentPageSelectedAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/isCurrentPageSelectedAtom.spec.ts new file mode 100644 index 0000000000..6e851c2b84 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/isCurrentPageSelectedAtom.spec.ts @@ -0,0 +1,77 @@ +import { configure, observable } from "mobx"; +import { isCurrentPageSelectedAtom } from "../models/selection.model.js"; + +describe("isCurrentPageSelectedAtom", () => { + configure({ + enforceActions: "never" + }); + + it("returns true when all current page items are selected", () => { + const gate = observable({ + props: { + itemSelection: { type: "Multi" as const, selection: [{ id: "1" }, { id: "2" }] }, + datasource: { items: [{ id: "1" }, { id: "2" }] } + } + }); + const atom = isCurrentPageSelectedAtom(gate); + expect(atom.get()).toBe(true); + }); + + it("returns false when only some page items are selected", () => { + const gate = observable({ + props: { + itemSelection: { type: "Multi" as const, selection: [{ id: "1" }] }, + datasource: { items: [{ id: "1" }, { id: "2" }] } + } + }); + const atom = isCurrentPageSelectedAtom(gate); + expect(atom.get()).toBe(false); + }); + + it("returns false when selection type is Single", () => { + const gate = observable({ + props: { + itemSelection: { type: "Single" as const }, + datasource: { items: [{ id: "1" }] } + } + }); + const atom = isCurrentPageSelectedAtom(gate); + expect(atom.get()).toBe(false); + }); + + it("returns false when itemSelection is undefined", () => { + const gate = observable({ + props: { + datasource: { items: [{ id: "1" }] } + } + }); + const atom = isCurrentPageSelectedAtom(gate); + expect(atom.get()).toBe(false); + }); + + it("returns false when there are no items", () => { + const gate = observable({ + props: { + itemSelection: { type: "Multi" as const, selection: [] }, + datasource: { items: [] } + } + }); + const atom = isCurrentPageSelectedAtom(gate); + expect(atom.get()).toBe(false); + }); + + it("updates reactively when selection changes", () => { + const gate = observable({ + props: { + itemSelection: { type: "Multi" as const, selection: [{ id: "1" }] }, + datasource: { items: [{ id: "1" }, { id: "2" }] } + } + }); + const atom = isCurrentPageSelectedAtom(gate); + + expect(atom.get()).toBe(false); + + gate.props.itemSelection.selection.push({ id: "2" }); + expect(atom.get()).toBe(true); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/itemCountAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/itemCountAtom.spec.ts new file mode 100644 index 0000000000..2d280c2b36 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/itemCountAtom.spec.ts @@ -0,0 +1,43 @@ +import { DerivedGate, GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { list } from "@mendix/widget-plugin-test-utils"; +import { ListValue } from "mendix"; +import { autorun } from "mobx"; +import { itemCountAtom } from "../models/datasource.model.js"; + +describe("itemCountAtom", () => { + it("returns -1 when datasource items is undefined", () => { + const gate = new DerivedGate({ props: { datasource: { items: undefined } } }); + + expect(itemCountAtom(gate).get()).toBe(-1); + }); + + it("returns correct count when datasource has items", () => { + const gate = new DerivedGate({ props: { datasource: list(5) } }); + + expect(itemCountAtom(gate).get()).toBe(5); + }); + + it("returns 0 for empty items array", () => { + const gate = new DerivedGate({ props: { datasource: list(0) } }); + + expect(itemCountAtom(gate).get()).toBe(0); + }); + + it("reacts to datasource items changes", () => { + const gateProvider = new GateProvider({ datasource: { items: undefined } as ListValue }); + const atom = itemCountAtom(gateProvider.gate); + const values: number[] = []; + + autorun(() => values.push(atom.get())); + + expect(values.at(0)).toBe(-1); + + gateProvider.setProps({ datasource: list(5) }); + gateProvider.setProps({ datasource: list(2) }); + gateProvider.setProps({ datasource: list(0) }); + gateProvider.setProps({ datasource: { items: undefined } as ListValue }); + gateProvider.setProps({ datasource: list(3) }); + + expect(values).toEqual([-1, 5, 2, 0, -1, 3]); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/limitAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/limitAtom.spec.ts new file mode 100644 index 0000000000..a54a0e00b1 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/limitAtom.spec.ts @@ -0,0 +1,23 @@ +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { autorun } from "mobx"; +import { limitAtom } from "../models/datasource.model.js"; + +describe("limitAtom", () => { + it("reacts to datasource limit changes", () => { + const gateProvider = new GateProvider({ datasource: { limit: 10 } }); + const atom = limitAtom(gateProvider.gate); + const values: number[] = []; + + autorun(() => values.push(atom.get())); + + expect(values.at(0)).toBe(10); + + gateProvider.setProps({ datasource: { limit: 25 } }); + gateProvider.setProps({ datasource: { limit: 50 } }); + gateProvider.setProps({ datasource: { limit: 5 } }); + gateProvider.setProps({ datasource: { limit: 10 } }); + gateProvider.setProps({ datasource: { limit: 100 } }); + + expect(values).toEqual([10, 25, 50, 5, 10, 100]); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/offsetAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/offsetAtom.spec.ts new file mode 100644 index 0000000000..0b4a5a6b8c --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/offsetAtom.spec.ts @@ -0,0 +1,23 @@ +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { autorun } from "mobx"; +import { offsetAtom } from "../models/datasource.model.js"; + +describe("offsetAtom", () => { + it("reacts to datasource offset changes", () => { + const gateProvider = new GateProvider({ datasource: { offset: 0 } }); + const atom = offsetAtom(gateProvider.gate); + const values: number[] = []; + + autorun(() => values.push(atom.get())); + + expect(values.at(0)).toBe(0); + + gateProvider.setProps({ datasource: { offset: 10 } }); + gateProvider.setProps({ datasource: { offset: 20 } }); + gateProvider.setProps({ datasource: { offset: 5 } }); + gateProvider.setProps({ datasource: { offset: 0 } }); + gateProvider.setProps({ datasource: { offset: 100 } }); + + expect(values).toEqual([0, 10, 20, 5, 0, 100]); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts new file mode 100644 index 0000000000..df88d3cd49 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts @@ -0,0 +1,36 @@ +import { configure, observable } from "mobx"; +import { selectedCountMulti } from "../models/selection.model.js"; + +describe("selectedCountMulti", () => { + configure({ + enforceActions: "never" + }); + + it("returns selection length when type is Multi", () => { + const gate = observable({ itemSelection: { type: "Multi", selection: [{ id: "1" }, { id: "2" }] } }); + const atom = selectedCountMulti(gate); + expect(atom.get()).toBe(2); + }); + + it("returns -1 when type is Single", () => { + const gate = observable({ itemSelection: { type: "Single", selection: [] } }); + const atom = selectedCountMulti(gate); + expect(atom.get()).toBe(-1); + }); + + it("returns -1 when itemSelection is undefined", () => { + const gate = observable({}); + const atom = selectedCountMulti(gate); + expect(atom.get()).toBe(-1); + }); + + it("updates reactively when selection changes", () => { + const gate = observable({ itemSelection: { type: "Multi", selection: [{ id: "1" }] } }); + const atom = selectedCountMulti(gate); + + expect(atom.get()).toBe(1); + + gate.itemSelection.selection.push({ id: "2" }); + expect(atom.get()).toBe(2); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/totalCountAtom.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/totalCountAtom.spec.ts new file mode 100644 index 0000000000..b7a3f39847 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/totalCountAtom.spec.ts @@ -0,0 +1,43 @@ +import { DerivedGate, GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; +import { autorun } from "mobx"; +import { totalCountAtom } from "../models/datasource.model.js"; + +describe("totalCountAtom", () => { + it("returns -1 when datasource totalCount is undefined", () => { + const gate = new DerivedGate({ props: { datasource: { totalCount: undefined } } }); + + expect(totalCountAtom(gate).get()).toBe(-1); + }); + + it("returns correct count when datasource has totalCount", () => { + const gate = new DerivedGate({ props: { datasource: { totalCount: 5 } } }); + + expect(totalCountAtom(gate).get()).toBe(5); + }); + + it("returns 0 for totalCount of 0", () => { + const gate = new DerivedGate({ props: { datasource: { totalCount: 0 } } }); + + expect(totalCountAtom(gate).get()).toBe(0); + }); + + it("reacts to datasource totalCount changes", () => { + const gateProvider = new GateProvider<{ datasource: { totalCount?: number } }>({ + datasource: { totalCount: undefined } + }); + const atom = totalCountAtom(gateProvider.gate); + const values: number[] = []; + + autorun(() => values.push(atom.get())); + + expect(values.at(0)).toBe(-1); + + gateProvider.setProps({ datasource: { totalCount: 5 } }); + gateProvider.setProps({ datasource: { totalCount: 2 } }); + gateProvider.setProps({ datasource: { totalCount: 0 } }); + gateProvider.setProps({ datasource: { totalCount: undefined } }); + gateProvider.setProps({ datasource: { totalCount: 3 } }); + + expect(values).toEqual([-1, 5, 2, 0, -1, 3]); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/core/models/datasource.model.ts b/packages/shared/widget-plugin-grid/src/core/models/datasource.model.ts new file mode 100644 index 0000000000..960ef5f86a --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/models/datasource.model.ts @@ -0,0 +1,65 @@ +import { atomFactory, ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { computed } from "mobx"; + +/** + * Atom returns `-1` when item count is unknown. + * @injectable + */ +export function itemCountAtom( + gate: DerivedPropsGate<{ datasource: { items?: { length: number } } }> +): ComputedAtom { + return computed(() => gate.props.datasource.items?.length ?? -1); +} + +/** + * Atom returns `-1` when total count is unavailable. + * @injectable + */ +export function totalCountAtom(gate: DerivedPropsGate<{ datasource: { totalCount?: number } }>): ComputedAtom { + return computed(() => totalCount(gate.props.datasource)); +} + +export function totalCount(ds: { totalCount?: number }): number { + return ds.totalCount ?? -1; +} + +/** + * Select offset of the datasource. + * @injectable + */ +export function offsetAtom(gate: DerivedPropsGate<{ datasource: { offset: number } }>): ComputedAtom { + return computed(() => gate.props.datasource.offset); +} + +/** + * Selects limit of the datasource. + * @injectable + */ +export function limitAtom(gate: DerivedPropsGate<{ datasource: { limit: number } }>): ComputedAtom { + return computed(() => gate.props.datasource.limit); +} + +/** + * Selects hasMoreItems flag of the datasource. + * @injectable + */ +export function hasMoreItemsAtom( + gate: DerivedPropsGate<{ datasource: { hasMoreItems?: boolean } }> +): ComputedAtom { + return computed(() => gate.props.datasource.hasMoreItems); +} + +export function isAllItemsPresent(offset: number, hasMoreItems?: boolean): boolean { + return offset === 0 && hasMoreItems === false; +} + +/** + * Atom returns `true` if all items are present in the datasource. + * @injectable + */ +export const isAllItemsPresentAtom = atomFactory( + (offset: ComputedAtom, hasMoreItems: ComputedAtom) => { + return [offset.get(), hasMoreItems.get()]; + }, + isAllItemsPresent +); diff --git a/packages/shared/widget-plugin-grid/src/core/models/empty-state.model.ts b/packages/shared/widget-plugin-grid/src/core/models/empty-state.model.ts new file mode 100644 index 0000000000..eecdc7d48e --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/models/empty-state.model.ts @@ -0,0 +1,20 @@ +import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { computed } from "mobx"; +import { ReactNode } from "react"; + +/** + * Selects 'empty placeholder' widgets from gate. + * @injectable + */ +export function emptyStateWidgetsAtom( + gate: DerivedPropsGate<{ emptyPlaceholder?: ReactNode }>, + itemsCount: ComputedAtom +): ComputedAtom { + return computed(() => { + const { emptyPlaceholder } = gate.props; + if (emptyPlaceholder && itemsCount.get() === 0) { + return emptyPlaceholder; + } + return null; + }); +} diff --git a/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts new file mode 100644 index 0000000000..7ba166f5d2 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts @@ -0,0 +1,70 @@ +import { atomFactory, ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { computed } from "mobx"; + +/** Returns selected count in multi-selection mode and -1 otherwise. */ +export function selectedCountMulti(gate: { + itemSelection?: { type: string; selection: { length: number } }; +}): ComputedAtom { + return computed(() => { + if (gate.itemSelection?.type === "Multi") { + return gate.itemSelection.selection.length; + } + return -1; + }); +} + +/** Returns true if all available items selected. */ +export function isAllItemsSelected( + selectedCount: number, + itemCount: number, + totalCount: number, + isAllItemsPresent: boolean +): boolean { + if (selectedCount < 1) return false; + if (totalCount > 0) return selectedCount === totalCount; + if (isAllItemsPresent) return selectedCount === itemCount; + return false; +} + +/** @injectable */ +export const isAllItemsSelectedAtom = atomFactory( + ( + selectedCount: ComputedAtom, + itemCount: ComputedAtom, + totalCount: ComputedAtom, + isAllItemsPresent: ComputedAtom + ): Parameters => { + return [selectedCount.get(), itemCount.get(), totalCount.get(), isAllItemsPresent.get()]; + }, + isAllItemsSelected +); + +type Item = { id: string }; + +/** Return true if all items on current page selected. */ +export function isCurrentPageSelected(selection: Item[], items: Item[]): boolean { + const pageIds = new Set(items.map(item => item.id)); + const selectionSubArray = selection.filter(item => pageIds.has(item.id)); + return selectionSubArray.length === pageIds.size && pageIds.size > 0; +} + +/** + * Atom returns true if all *loaded* items are selected. + * @injectable + */ +export function isCurrentPageSelectedAtom( + gate: DerivedPropsGate<{ + itemSelection?: { type: "Single" } | { type: "Multi"; selection: Item[] }; + datasource: { items?: Item[] }; + }> +): ComputedAtom { + return computed(() => { + // Read props first to track changes + const selection = gate.props.itemSelection; + const items = gate.props.datasource.items ?? []; + + if (!selection || selection.type === "Single") return false; + + return isCurrentPageSelected(selection.selection, items); + }); +} diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts new file mode 100644 index 0000000000..11c01df366 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts @@ -0,0 +1,37 @@ +import { action, makeObservable, observable } from "mobx"; +import { BarStore } from "./select-all.model"; + +export class SelectAllBarStore implements BarStore { + pending = false; + visible = false; + clearBtnVisible = false; + + constructor() { + makeObservable(this, { + pending: observable, + visible: observable, + clearBtnVisible: observable, + setClearBtnVisible: action, + setPending: action, + hideBar: action, + showBar: action + }); + } + + setClearBtnVisible(value: boolean): void { + this.clearBtnVisible = value; + } + + setPending(value: boolean): void { + this.pending = value; + } + + hideBar(): void { + this.visible = false; + this.clearBtnVisible = false; + } + + showBar(): void { + this.visible = true; + } +} diff --git a/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts b/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts new file mode 100644 index 0000000000..183d6bb42b --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts @@ -0,0 +1,30 @@ +import { ComputedAtom, disposeBatch, Emitter, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; + +import { + BarStore, + SelectAllEvents, + SelectService, + setupBarStore, + setupSelectService, + setupVisibilityEvents +} from "./select-all.model"; + +export class SelectAllFeature implements SetupComponent { + constructor( + private emitter: Emitter, + private service: SelectService, + private store: BarStore, + private isCurrentPageSelected: ComputedAtom, + private isAllSelected: ComputedAtom + ) {} + + setup(): () => void { + const [add, disposeAll] = disposeBatch(); + + add(setupBarStore(this.store, this.emitter)); + add(setupSelectService(this.service, this.emitter)); + add(setupVisibilityEvents(this.isCurrentPageSelected, this.isAllSelected, this.emitter)); + + return disposeAll; + } +} diff --git a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts new file mode 100644 index 0000000000..447b41aeb8 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts @@ -0,0 +1,114 @@ +import { ComputedAtom, createEmitter, disposeBatch, Emitter } from "@mendix/widget-plugin-mobx-kit/main"; +import { reaction } from "mobx"; + +export type ServiceEvents = { + loadstart: ProgressEvent; + progress: ProgressEvent; + done: { success: boolean }; + loadend: undefined; +}; + +export type UIEvents = { + visibility: { visible: boolean }; + startSelecting: undefined; + clear: undefined; + abort: undefined; +}; + +type Handler = (event: T[K]) => void; + +type PrettyType = { [K in keyof T]: T[K] }; + +export type SelectAllEvents = PrettyType; + +/** @injectable */ +export function selectAllEmitter(): Emitter { + return createEmitter(); +} + +export interface BarStore { + pending: boolean; + visible: boolean; + clearBtnVisible: boolean; + setClearBtnVisible(value: boolean): void; + setPending(value: boolean): void; + hideBar(): void; + showBar(): void; +} + +export interface SelectService { + selectAllPages(): void; + clearSelection(): void; + abort(): void; +} + +export function setupBarStore(store: BarStore, emitter: Emitter): () => void { + const [add, disposeAll] = disposeBatch(); + + const handleVisibility: Handler = (event): void => { + if (event.visible) { + store.showBar(); + } else { + store.hideBar(); + } + }; + + const handleLoadStart = (): void => store.setPending(true); + + const handleLoadEnd = (): void => store.setPending(false); + + const handleDone: Handler = (event): void => { + store.setClearBtnVisible(event.success); + }; + + add(emitter.on("visibility", handleVisibility)); + add(emitter.on("loadstart", handleLoadStart)); + add(emitter.on("loadend", handleLoadEnd)); + add(emitter.on("done", handleDone)); + + return disposeAll; +} + +export function setupSelectService(service: SelectService, emitter: Emitter): () => void { + const [add, disposeAll] = disposeBatch(); + + add(emitter.on("startSelecting", () => service.selectAllPages())); + add(emitter.on("clear", () => service.clearSelection())); + add(emitter.on("abort", () => service.abort())); + + return disposeAll; +} + +export function setupProgressService( + service: { + onloadstart: (event: ProgressEvent) => void; + onprogress: (event: ProgressEvent) => void; + onloadend: () => void; + }, + emitter: Emitter +): () => void { + const [add, disposeAll] = disposeBatch(); + + add(emitter.on("loadstart", event => service.onloadstart(event))); + add(emitter.on("progress", event => service.onprogress(event))); + add(emitter.on("loadend", () => service.onloadend())); + + return disposeAll; +} + +export function setupVisibilityEvents( + isPageSelected: ComputedAtom, + isAllSelected: ComputedAtom, + emitter: Emitter +): () => void { + return reaction( + () => [isPageSelected.get(), isAllSelected.get()] as const, + ([isPageSelected, isAllSelected]) => { + if (isPageSelected === false) { + emitter.emit("visibility", { visible: false }); + } else if (isAllSelected === false) { + emitter.emit("visibility", { visible: true }); + } + } + ); +} diff --git a/packages/shared/widget-plugin-grid/src/utils/mobx-test-setup.ts b/packages/shared/widget-plugin-grid/src/utils/mobx-test-setup.ts new file mode 100644 index 0000000000..f3410aabae --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/utils/mobx-test-setup.ts @@ -0,0 +1,3 @@ +import { configure } from "mobx"; + +configure({ enforceActions: "never" }); From 85c566297f1558c5aef912c4e8ee0ae1a5e7358f Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:42:10 +0100 Subject: [PATCH 2/7] refactor: rewrite empty placeholder --- .../datagrid-web/src/Datagrid.tsx | 7 +-- .../datagrid-web/src/components/Widget.tsx | 23 +------- .../empty-message/EmptyPlaceholder.tsx | 16 ++++++ .../EmptyPlaceholder.viewModel.ts | 32 ++++++++++++ .../EmptyPlaceholder.viewModel.spec.ts | 52 +++++++++++++++++++ .../features/empty-message/injection-hooks.ts | 4 ++ .../model/containers/Datagrid.container.ts | 8 +++ .../src/model/models/columns.model.ts | 7 +++ .../datagrid-web/src/model/tokens.ts | 21 +++++++- .../datagrid-web/typings/MainGateProps.ts | 1 + 10 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.viewModel.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/empty-message/__tests__/EmptyPlaceholder.viewModel.spec.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index f6f3a80d50..49cfaaa2aa 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -5,7 +5,7 @@ import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { ContainerProvider } from "brandi-react"; import { observer } from "mobx-react-lite"; -import { ReactElement, ReactNode, useCallback, useMemo } from "react"; +import { ReactElement, useCallback, useMemo } from "react"; import { DatagridContainerProps } from "../typings/DatagridProps"; import { Cell } from "./components/Cell"; import { Widget } from "./components/Widget"; @@ -84,11 +84,6 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { columnsResizable={props.columnsResizable} columnsSortable={props.columnsSortable} data={items} - emptyPlaceholderRenderer={useCallback( - (renderWrapper: (children: ReactNode) => ReactElement) => - props.showEmptyPlaceholder === "custom" ? renderWrapper(props.emptyPlaceholder) :
, - [props.emptyPlaceholder, props.showEmptyPlaceholder] - )} filterRenderer={useCallback( (renderWrapper, columnIndex) => { const columnFilter = columnsStore.columnFilters[columnIndex]; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 81a13fb18c..39604e653a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -1,7 +1,6 @@ import { RefreshIndicator } from "@mendix/widget-plugin-component-kit/RefreshIndicator"; import { Pagination } from "@mendix/widget-plugin-grid/components/Pagination"; import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import classNames from "classnames"; import { ListActionValue, ObjectItem } from "mendix"; import { observer } from "mobx-react-lite"; import { CSSProperties, Fragment, ReactElement, ReactNode } from "react"; @@ -12,6 +11,7 @@ import { ShowPagingButtonsEnum } from "../../typings/DatagridProps"; +import { EmptyPlaceholder } from "../features/empty-message/EmptyPlaceholder"; import { SelectAllBar } from "../features/select-all/SelectAllBar"; import { SelectionProgressDialog } from "../features/select-all/SelectionProgressDialog"; import { SelectActionHelper } from "../helpers/SelectActionHelper"; @@ -38,7 +38,6 @@ export interface WidgetProps ReactElement) => ReactElement; exporting: boolean; filterRenderer: (renderWrapper: (children: ReactNode) => ReactElement, columnIndex: number) => ReactElement; hasMoreItems: boolean; @@ -117,7 +116,6 @@ const Main = observer((props: WidgetProps): ReactElemen CellComponent, columnsHidable, data: rows, - emptyPlaceholderRenderer, hasMoreItems, headerContent, headerTitle, @@ -128,7 +126,6 @@ const Main = observer((props: WidgetProps): ReactElemen paginationType, paging, pagingPosition, - preview, showRefreshIndicator, selectActionHelper, setPage, @@ -216,23 +213,7 @@ const Main = observer((props: WidgetProps): ReactElemen eventsController={props.cellEventsController} pageSize={props.pageSize} /> - {(rows.length === 0 || preview) && - emptyPlaceholderRenderer && - emptyPlaceholderRenderer(children => ( -
-
{children}
-
- ))} + diff --git a/packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.tsx b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.tsx new file mode 100644 index 0000000000..c42424df51 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.tsx @@ -0,0 +1,16 @@ +import classNames from "classnames"; +import { observer } from "mobx-react-lite"; +import { ReactNode } from "react"; +import { useEmptyPlaceholderVM } from "./injection-hooks"; + +export const EmptyPlaceholder = observer(function EmptyPlaceholder(): ReactNode { + const vm = useEmptyPlaceholderVM(); + + if (!vm.content) return null; + + return ( +
+
{vm.content}
+
+ ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.viewModel.ts new file mode 100644 index 0000000000..3b88256534 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/EmptyPlaceholder.viewModel.ts @@ -0,0 +1,32 @@ +import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; +import { makeAutoObservable } from "mobx"; +import { CSSProperties, ReactNode } from "react"; + +export class EmptyPlaceholderViewModel { + constructor( + private widgets: ComputedAtom, + private visibleColumnsCount: ComputedAtom, + private config: { checkboxColumnEnabled: boolean; selectorColumnEnabled: boolean } + ) { + makeAutoObservable(this); + } + + get content(): ReactNode { + return this.widgets.get(); + } + + get span(): number { + let span = this.visibleColumnsCount.get(); + if (this.config.checkboxColumnEnabled) { + span += 1; + } + if (this.config.selectorColumnEnabled) { + span += 1; + } + return Math.max(span, 1); + } + + get style(): CSSProperties { + return { gridColumn: `span ${this.span}` }; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/empty-message/__tests__/EmptyPlaceholder.viewModel.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/__tests__/EmptyPlaceholder.viewModel.spec.ts new file mode 100644 index 0000000000..c06a6177dd --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/__tests__/EmptyPlaceholder.viewModel.spec.ts @@ -0,0 +1,52 @@ +import { computed, observable } from "mobx"; +import { ReactNode } from "react"; +import { EmptyPlaceholderViewModel } from "../EmptyPlaceholder.viewModel"; + +describe("EmptyPlaceholderViewModel", () => { + describe("style getter", () => { + it("reacts to changes in visible columns count", () => { + const mockWidgets = computed(() => "Empty message" as ReactNode); + const columnCount = observable.box(3); + const config = { checkboxColumnEnabled: false, selectorColumnEnabled: false }; + + const viewModel = new EmptyPlaceholderViewModel(mockWidgets, columnCount, config); + + expect(viewModel.style).toEqual({ gridColumn: "span 3" }); + + columnCount.set(5); + expect(viewModel.style).toEqual({ gridColumn: "span 5" }); + + columnCount.set(0); + expect(viewModel.style).toEqual({ gridColumn: "span 1" }); + }); + + it("reacts to changes in visible columns count with config flags enabled", () => { + const mockWidgets = computed(() => "Empty message" as ReactNode); + const columnCount = observable.box(3); + const config = { checkboxColumnEnabled: true, selectorColumnEnabled: true }; + + const viewModel = new EmptyPlaceholderViewModel(mockWidgets, columnCount, config); + + expect(viewModel.style).toEqual({ gridColumn: "span 5" }); + + columnCount.set(5); + expect(viewModel.style).toEqual({ gridColumn: "span 7" }); + + columnCount.set(0); + expect(viewModel.style).toEqual({ gridColumn: "span 2" }); + }); + }); + + describe("content getter", () => { + it("returns widgets from atom", () => { + const message = "Empty message"; + const atom = computed(() => message); + const columnCount = observable.box(3); + const config = { checkboxColumnEnabled: false, selectorColumnEnabled: false }; + + const viewModel = new EmptyPlaceholderViewModel(atom, columnCount, config); + + expect(viewModel.content).toBe(message); + }); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts new file mode 100644 index 0000000000..5ed4551bb2 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts @@ -0,0 +1,4 @@ +import { createInjectionHooks } from "brandi-react"; +import { TOKENS } from "../../model/tokens"; + +export const [useEmptyPlaceholderVM] = createInjectionHooks(TOKENS.emptyPlaceholderVM); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts index df2664ce0a..cab8e70eac 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -1,6 +1,8 @@ import { WidgetFilterAPI } 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 { itemCountAtom } from "@mendix/widget-plugin-grid/core/models/datasource.model"; +import { emptyStateWidgetsAtom } from "@mendix/widget-plugin-grid/core/models/empty-state.model"; import { DatasourceService, ProgressService, SelectionCounterViewModel } from "@mendix/widget-plugin-grid/main"; import { ClosableGateProvider } from "@mendix/widget-plugin-mobx-kit/ClosableGateProvider"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; @@ -13,6 +15,7 @@ import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore"; import { DatagridConfig, datagridConfig } from "../configs/Datagrid.config"; +import { visibleColumnsCountAtom } from "../models/columns.model"; import { DatasourceParamsController } from "../services/DatasourceParamsController"; import { DerivedLoaderController } from "../services/DerivedLoaderController"; import { PaginationController } from "../services/PaginationController"; @@ -125,6 +128,11 @@ export class DatagridContainer extends Container { // Bind select all enabled flag this.bind(TOKENS.enableSelectAll).toConstant(props.enableSelectAll); + // Atoms + this.bind(TOKENS.visibleColumnsCount).toInstance(visibleColumnsCountAtom).inTransientScope(); + this.bind(TOKENS.visibleRowCount).toInstance(itemCountAtom).inTransientScope(); + this.bind(TOKENS.emptyPlaceholderWidgets).toInstance(emptyStateWidgetsAtom).inTransientScope(); + this.postInit(props, config); return this; diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts new file mode 100644 index 0000000000..f3ba7dd46b --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts @@ -0,0 +1,7 @@ +import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; +import { computed } from "mobx"; + +/** @injectable */ +export function visibleColumnsCountAtom(source: { visibleColumns: { length: number } }): ComputedAtom { + return computed(() => source.visibleColumns.length); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index 76b7510f47..c970f1b089 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -1,6 +1,8 @@ import { FilterAPI, WidgetFilterAPI } from "@mendix/widget-plugin-filtering/context"; import { CombinedFilter, CombinedFilterConfig } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter"; import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; +import { itemCountAtom } from "@mendix/widget-plugin-grid/core/models/datasource.model"; +import { emptyStateWidgetsAtom } from "@mendix/widget-plugin-grid/core/models/empty-state.model"; import { DatasourceService, QueryService, @@ -8,11 +10,13 @@ import { SelectionCounterViewModel, TaskProgressService } from "@mendix/widget-plugin-grid/main"; -import { DerivedPropsGate, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { ComputedAtom, DerivedPropsGate, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; import { injected, token } from "brandi"; import { ListValue } from "mendix"; +import { ReactNode } from "react"; import { SelectionCounterPositionEnum } from "../../typings/DatagridProps"; import { MainGateProps } from "../../typings/MainGateProps"; +import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel"; import { SelectAllBarViewModel } from "../features/select-all/SelectAllBar.viewModel"; import { SelectAllGateProps } from "../features/select-all/SelectAllGateProps"; import { SelectionProgressDialogViewModel } from "../features/select-all/SelectionProgressDialog.viewModel"; @@ -21,6 +25,7 @@ import { GridBasicData } from "../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../helpers/state/GridPersonalizationStore"; import { DatasourceParamsController } from "../model/services/DatasourceParamsController"; import { DatagridConfig } from "./configs/Datagrid.config"; +import { visibleColumnsCountAtom } from "./models/columns.model"; import { DerivedLoaderController, DerivedLoaderControllerConfig } from "./services/DerivedLoaderController"; import { PaginationConfig, PaginationController } from "./services/PaginationController"; @@ -31,6 +36,8 @@ export const TOKENS = { combinedFilter: token("CombinedFilter"), combinedFilterConfig: token("CombinedFilterKey"), config: token("DatagridConfig"), + emptyPlaceholderVM: token("EmptyPlaceholderViewModel"), + emptyPlaceholderWidgets: token>("@computed:emptyPlaceholder"), enableSelectAll: token("enableSelectAll"), exportProgressService: token("ExportProgressService"), filterAPI: token("FilterAPI"), @@ -53,7 +60,9 @@ export const TOKENS = { selectionCounterPosition: token("SelectionCounterPositionEnum"), selectionCounterVM: token("SelectionCounterViewModel"), selectionDialogVM: token("SelectionProgressDialogViewModel"), - setupService: token("DatagridSetupHost") + setupService: token("DatagridSetupHost"), + visibleColumnsCount: token>("@computed:visibleColumnsCount"), + visibleRowCount: token>("@computed:visibleRowCount") }; /** Inject dependencies */ @@ -96,3 +105,11 @@ injected( TOKENS.selectAllProgressService, TOKENS.selectAllService ); + +injected(EmptyPlaceholderViewModel, TOKENS.emptyPlaceholderWidgets, TOKENS.visibleColumnsCount, TOKENS.config); + +injected(visibleColumnsCountAtom, TOKENS.columnsStore); + +injected(itemCountAtom, TOKENS.mainGate); + +injected(emptyStateWidgetsAtom, TOKENS.mainGate, TOKENS.visibleRowCount); diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts index 6567fd1986..2f08c4fc2a 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts @@ -26,4 +26,5 @@ export type MainGateProps = Pick< | "cancelSelectionLabel" | "selectionCounterPosition" | "enableSelectAll" + | "emptyPlaceholder" >; From 163dc897b3d57a35a71b0a496eefb574ae37581f Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:22:20 +0100 Subject: [PATCH 3/7] refactor: separate code in plugin --- .../src/core/models/selection.model.ts | 37 ++++- .../src/select-all/SelectAll.service.ts | 40 ++---- .../src/select-all/SelectAllBar.store.ts | 12 +- .../src/select-all/select-all.model.ts | 49 ++++++- .../SelectionCounter.viewModel-atoms.ts | 38 +++++ .../SelectionCounter.viewModel.spec.ts | 135 +++++++++--------- 6 files changed, 199 insertions(+), 112 deletions(-) create mode 100644 packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts diff --git a/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts index 7ba166f5d2..7c2cfe811f 100644 --- a/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts +++ b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts @@ -1,7 +1,11 @@ import { atomFactory, ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; -import { computed } from "mobx"; +import { DynamicValue } from "mendix"; +import { computed, observable } from "mobx"; -/** Returns selected count in multi-selection mode and -1 otherwise. */ +/** + * Returns selected count in multi-selection mode and -1 otherwise. + * @injectable + */ export function selectedCountMulti(gate: { itemSelection?: { type: string; selection: { length: number } }; }): ComputedAtom { @@ -68,3 +72,32 @@ export function isCurrentPageSelectedAtom( return isCurrentPageSelected(selection.selection, items); }); } + +interface ObservableSelectorTexts { + clearSelectionButtonLabel: string; + selectedCountText: string; +} + +export function selectedCounterTextsStore( + gate: DerivedPropsGate<{ + clearSelectionButtonLabel?: DynamicValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; + }>, + selectedCount: ComputedAtom +): ObservableSelectorTexts { + return observable({ + get clearSelectionButtonLabel() { + return gate.props.clearSelectionButtonLabel?.value || "Clear selection"; + }, + get selectedCountText() { + const formatSingular = gate.props.selectedCountTemplateSingular?.value || "%d item selected"; + const formatPlural = gate.props.selectedCountTemplatePlural?.value || "%d items selected"; + const count = selectedCount.get(); + + if (count > 1) return formatPlural.replace("%d", `${count}`); + if (count === 1) return formatSingular.replace("%d", "1"); + return ""; + } + }); +} diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts index 4a296d2aee..50ab21528a 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts @@ -1,30 +1,26 @@ -import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { DerivedPropsGate, Emitter } from "@mendix/widget-plugin-mobx-kit/main"; import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { action, computed, makeObservable, observable, when } from "mobx"; import { QueryService } from "../interfaces/QueryService"; -import { TaskProgressService } from "../interfaces/TaskProgressService"; +import { ServiceEvents } from "./select-all.model"; interface DynamicProps { itemSelection?: SelectionMultiValue | SelectionSingleValue; } -export class SelectAllService implements SetupComponent { +export class SelectAllService { private locked = false; private abortController?: AbortController; private readonly pageSize = 1024; constructor( - host: SetupComponentHost, private gate: DerivedPropsGate, private query: QueryService, - private progress: TaskProgressService + private progress: Emitter ) { - host.add(this); - type PrivateMembers = "setIsLocked" | "locked"; + type PrivateMembers = "locked"; makeObservable(this, { - setIsLocked: action, canExecute: computed, - isExecuting: computed, selection: computed, locked: observable, selectAllPages: action, @@ -33,10 +29,6 @@ export class SelectAllService implements SetupComponent { }); } - setup(): () => void { - return () => this.abort(); - } - get selection(): SelectionMultiValue | undefined { const selection = this.gate.props.itemSelection; if (selection === undefined) return; @@ -48,14 +40,6 @@ export class SelectAllService implements SetupComponent { return this.gate.props.itemSelection?.type === "Multi" && !this.locked; } - get isExecuting(): boolean { - return this.locked; - } - - private setIsLocked(value: boolean): void { - this.locked = value; - } - private beforeRunChecks(): boolean { const selection = this.gate.props.itemSelection; @@ -94,8 +78,10 @@ export class SelectAllService implements SetupComponent { return { success: false }; } - this.setIsLocked(true); + this.locked = true; + this.abortController = new AbortController(); + const signal = this.abortController.signal; const { offset: initOffset, limit: initLimit } = this.query; const initSelection = this.selection?.selection ?? []; const hasTotal = typeof this.query.totalCount === "number"; @@ -107,12 +93,10 @@ export class SelectAllService implements SetupComponent { new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); // We should avoid duplicates, so, we start with clean array. const allItems: ObjectItem[] = []; - this.abortController = new AbortController(); - const signal = this.abortController.signal; performance.mark("SelectAll_Start"); try { - this.progress.onloadstart(pe("loadstart")); + this.progress.emit("loadstart", pe("loadstart")); let loading = true; while (loading) { const loadedItems = await this.query.fetchPage({ @@ -124,7 +108,7 @@ export class SelectAllService implements SetupComponent { allItems.push(...loadedItems); loaded += loadedItems.length; offset += this.pageSize; - this.progress.onprogress(pe("progress")); + this.progress.emit("progress", pe("progress")); loading = !signal.aborted && this.query.hasMoreItems; } success = true; @@ -141,9 +125,7 @@ export class SelectAllService implements SetupComponent { offset: initOffset }); await this.reloadSelection(); - this.progress.onloadend(); - - // const selectionBeforeReload = this.selection?.selection ?? []; + this.progress.emit("loadend"); // Reload selection to make sure setSelection is working as expected. this.selection?.setSelection(success ? allItems : initSelection); this.locked = false; diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts index 11c01df366..58ecf9bc8a 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts @@ -1,4 +1,4 @@ -import { action, makeObservable, observable } from "mobx"; +import { makeAutoObservable } from "mobx"; import { BarStore } from "./select-all.model"; export class SelectAllBarStore implements BarStore { @@ -7,15 +7,7 @@ export class SelectAllBarStore implements BarStore { clearBtnVisible = false; constructor() { - makeObservable(this, { - pending: observable, - visible: observable, - clearBtnVisible: observable, - setClearBtnVisible: action, - setPending: action, - hideBar: action, - showBar: action - }); + makeAutoObservable(this); } setClearBtnVisible(value: boolean): void { diff --git a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts index 447b41aeb8..8adcddbb28 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts @@ -1,5 +1,12 @@ -import { ComputedAtom, createEmitter, disposeBatch, Emitter } from "@mendix/widget-plugin-mobx-kit/main"; -import { reaction } from "mobx"; +import { + ComputedAtom, + createEmitter, + DerivedPropsGate, + disposeBatch, + Emitter +} from "@mendix/widget-plugin-mobx-kit/main"; +import { DynamicValue } from "mendix"; +import { observable, reaction } from "mobx"; export type ServiceEvents = { loadstart: ProgressEvent; @@ -26,6 +33,43 @@ export function selectAllEmitter(): Emitter { return createEmitter(); } +interface ObservableSelectAllTexts { + selectionStatus: string; + selectAllLabel: string; +} + +/** @injectable */ +export function selectAllTextsStore( + gate: DerivedPropsGate<{ + allSelectedText?: DynamicValue; + selectAllTemplate?: DynamicValue; + selectAllText?: DynamicValue; + }>, + selectedCount: ComputedAtom, + selectedTexts: { selectedCountText: string }, + totalCount: ComputedAtom, + isAllItemsSelected: ComputedAtom +): ObservableSelectAllTexts { + return observable({ + get selectAllLabel() { + const selectAllFormat = gate.props.selectAllTemplate?.value || "Select all %d rows in the data source"; + const selectAllText = gate.props.selectAllText?.value || "Select all rows in the data source"; + const total = totalCount.get(); + if (total > 0) return selectAllFormat.replace("%d", `${total}`); + return selectAllText; + }, + get selectionStatus() { + if (isAllItemsSelected.get()) return this.allSelectedText; + return selectedTexts.selectedCountText; + }, + get allSelectedText() { + const str = gate.props.allSelectedText?.value ?? "All %d rows selected."; + const count = selectedCount.get(); + return str.replace("%d", `${count}`); + } + }); +} + export interface BarStore { pending: boolean; visible: boolean; @@ -75,6 +119,7 @@ export function setupSelectService(service: SelectService, emitter: Emitter service.selectAllPages())); add(emitter.on("clear", () => service.clearSelection())); add(emitter.on("abort", () => service.abort())); + add(() => service.abort()); return disposeAll; } diff --git a/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts new file mode 100644 index 0000000000..88bb7ec9a3 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts @@ -0,0 +1,38 @@ +import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; +import { makeAutoObservable } from "mobx"; + +/** @injectable */ +export class SelectionCounterViewModel { + constructor( + private selected: ComputedAtom, + private texts: { + clearSelectionButtonLabel: string; + selectedCountText: string; + }, + private position: "top" | "bottom" | "off" + ) { + makeAutoObservable(this); + } + + get isTopCounterVisible(): boolean { + if (this.position !== "top") return false; + return this.selected.get() > 0; + } + + get isBottomCounterVisible(): boolean { + if (this.position !== "bottom") return false; + return this.selected.get() > 0; + } + + get clearButtonLabel(): string { + return this.texts.clearSelectionButtonLabel; + } + + get selectedCount(): number { + return this.selected.get(); + } + + get selectedCountText(): string { + return this.texts.selectedCountText; + } +} diff --git a/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts b/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts index 2d655276e8..c5e607546c 100644 --- a/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts +++ b/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts @@ -1,100 +1,97 @@ -import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; -import { objectItems, SelectionMultiValueBuilder, SelectionSingleValueBuilder } from "@mendix/widget-plugin-test-utils"; -import { SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { SelectionCounterViewModel } from "../SelectionCounter.viewModel"; +import { computed, observable } from "mobx"; +import { SelectionCounterViewModel } from "../SelectionCounter.viewModel-atoms"; -type Props = { - itemSelection?: SelectionSingleValue | SelectionMultiValue; -}; +describe("SelectionCounterViewModel", () => { + describe("selectedCount", () => { + it("returns value from selected atom", () => { + const selected = computed(() => 5); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "5 items selected" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "top"); -const createMinimalMockProps = (overrides: Props = {}): Props => ({ ...overrides }); - -describe("SelectionCountStore", () => { - let gateProvider: GateProvider; - let selectionCountStore: SelectionCounterViewModel; + expect(viewModel.selectedCount).toBe(5); + }); - beforeEach(() => { - const mockProps = createMinimalMockProps(); - gateProvider = new GateProvider(mockProps); - selectionCountStore = new SelectionCounterViewModel(gateProvider.gate, "top"); - }); + it("updates reactively when atom changes", () => { + const selectedBox = observable.box(3); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selectedBox, texts, "top"); - describe("when itemSelection is undefined", () => { - it("should return 0 selected items", () => { - const props = createMinimalMockProps({ itemSelection: undefined }); - gateProvider.setProps(props); + expect(viewModel.selectedCount).toBe(3); - expect(selectionCountStore.selectedCount).toBe(0); + selectedBox.set(10); + expect(viewModel.selectedCount).toBe(10); }); }); - describe("when itemSelection is single selection", () => { - it("should return 0 when no item is selected", () => { - const singleSelection = new SelectionSingleValueBuilder().build(); - const props = createMinimalMockProps({ itemSelection: singleSelection }); - gateProvider.setProps(props); + describe("selectedCountText", () => { + it("returns value from texts object", () => { + const selected = computed(() => 5); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "5 items selected" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "top"); - expect(selectionCountStore.selectedCount).toBe(0); + expect(viewModel.selectedCountText).toBe("5 items selected"); }); + }); - it("should return 0 when one item is selected", () => { - const items = objectItems(3); - const singleSelection = new SelectionSingleValueBuilder().withSelected(items[0]).build(); - const props = createMinimalMockProps({ itemSelection: singleSelection }); - gateProvider.setProps(props); + describe("clearButtonLabel", () => { + it("returns value from texts object", () => { + const selected = computed(() => 0); + const texts = { clearSelectionButtonLabel: "Clear selection", selectedCountText: "" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "top"); - expect(selectionCountStore.selectedCount).toBe(0); + expect(viewModel.clearButtonLabel).toBe("Clear selection"); }); }); - describe("when itemSelection is multi selection", () => { - it("should return 0 when no items are selected", () => { - const multiSelection = new SelectionMultiValueBuilder().build(); - const props = createMinimalMockProps({ itemSelection: multiSelection }); - gateProvider.setProps(props); + describe("isTopCounterVisible", () => { + it("returns true when position is top and selectedCount > 0", () => { + const selected = computed(() => 5); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "top"); - expect(selectionCountStore.selectedCount).toBe(0); + expect(viewModel.isTopCounterVisible).toBe(true); }); - it("should return correct count when multiple items are selected", () => { - const items = objectItems(5); - const selectedItems = [items[0], items[2], items[4]]; - const multiSelection = new SelectionMultiValueBuilder().withSelected(selectedItems).build(); - const props = createMinimalMockProps({ itemSelection: multiSelection }); - gateProvider.setProps(props); + it("returns false when position is top but selectedCount is 0", () => { + const selected = computed(() => 0); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "top"); - expect(selectionCountStore.selectedCount).toBe(3); + expect(viewModel.isTopCounterVisible).toBe(false); }); - it("should return correct count when all items are selected", () => { - const items = objectItems(4); - const multiSelection = new SelectionMultiValueBuilder().withSelected(items).build(); - const props = createMinimalMockProps({ itemSelection: multiSelection }); - gateProvider.setProps(props); + it("returns false when position is not top", () => { + const selected = computed(() => 5); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "bottom"); - expect(selectionCountStore.selectedCount).toBe(4); + expect(viewModel.isTopCounterVisible).toBe(false); }); + }); + + describe("isBottomCounterVisible", () => { + it("returns true when position is bottom and selectedCount > 0", () => { + const selected = computed(() => 5); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "bottom"); - it("should reactively update when selection changes", () => { - const items = objectItems(3); - const multiSelection = new SelectionMultiValueBuilder().build(); - const props = createMinimalMockProps({ itemSelection: multiSelection }); - gateProvider.setProps(props); + expect(viewModel.isBottomCounterVisible).toBe(true); + }); - // Initially no items selected - expect(selectionCountStore.selectedCount).toBe(0); + it("returns false when position is bottom but selectedCount is 0", () => { + const selected = computed(() => 0); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "bottom"); - // Select one item - multiSelection.setSelection([items[0]]); - expect(selectionCountStore.selectedCount).toBe(1); + expect(viewModel.isBottomCounterVisible).toBe(false); + }); - // Select two more items - multiSelection.setSelection([items[0], items[1], items[2]]); - expect(selectionCountStore.selectedCount).toBe(3); + it("returns false when position is not bottom", () => { + const selected = computed(() => 5); + const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; + const viewModel = new SelectionCounterViewModel(selected, texts, "top"); - // Clear selection - multiSelection.setSelection([]); - expect(selectionCountStore.selectedCount).toBe(0); + expect(viewModel.isBottomCounterVisible).toBe(false); }); }); }); From 4d94c09c8fdc24894c56bed59a377a3ab2907289 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:55:51 +0100 Subject: [PATCH 4/7] refactor: split select-all feature --- .../features/empty-message/injection-hooks.ts | 4 +- .../select-all/SelectAllBar.viewModel.ts | 156 +++------------ .../features/select-all/SelectAllGateProps.ts | 6 - .../select-all/SelectAllModule.container.ts | 103 ++++++++-- .../features/select-all/injection-hooks.ts | 6 +- .../selection-counter/injection-hooks.ts | 4 +- .../src/model/configs/Datagrid.config.ts | 4 +- .../model/containers/Datagrid.container.ts | 181 +++++++++--------- .../src/model/containers/Root.container.ts | 62 +++++- .../src/model/hooks/injection-hooks.ts | 18 +- .../src/model/hooks/useDatagridContainer.ts | 34 +++- .../services/MainGateProvider.service.ts | 20 ++ .../datagrid-web/src/model/tokens.ts | 148 +++++++------- .../datagrid-web/typings/MainGateProps.ts | 33 ++-- .../core/__tests__/selectedCountMulti.spec.ts | 22 ++- .../src/core/models/selection.model.ts | 22 ++- .../src/select-all/SelectAll.service.ts | 6 +- .../src/select-all/select-all.feature.ts | 17 +- .../src/select-all/select-all.model.ts | 2 +- .../SelectionCounter.viewModel-atoms.ts | 6 +- .../SelectionCounter.viewModel.spec.ts | 20 +- 21 files changed, 466 insertions(+), 408 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts index 5ed4551bb2..e164f1909d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts @@ -1,4 +1,4 @@ import { createInjectionHooks } from "brandi-react"; -import { TOKENS } from "../../model/tokens"; +import { DG_TOKENS as DG } from "../../model/tokens"; -export const [useEmptyPlaceholderVM] = createInjectionHooks(TOKENS.emptyPlaceholderVM); +export const [useEmptyPlaceholderVM] = createInjectionHooks(DG.emptyPlaceholderVM); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts index 5f063b2f03..3f7c14f35a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts @@ -1,160 +1,54 @@ -import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; -import { DynamicValue, ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { action, makeAutoObservable, reaction } from "mobx"; - -type DynamicProps = { - datasource: ListValue; - selectAllTemplate?: DynamicValue; - selectAllText?: DynamicValue; - itemSelection?: SelectionSingleValue | SelectionMultiValue; - allSelectedText?: DynamicValue; -}; - -interface SelectService { - selectAllPages(): Promise<{ success: boolean }> | { success: boolean }; - clearSelection(): void; -} - -interface CounterService { - selectedCount: number; - selectedCountText: string; - clearButtonLabel: string; -} +import { SelectAllEvents } from "@mendix/widget-plugin-grid/select-all/select-all.model"; +import { Emitter } from "@mendix/widget-plugin-mobx-kit/main"; +import { makeAutoObservable } from "mobx"; /** @injectable */ -export class SelectAllBarViewModel implements SetupComponent { - private barVisible = false; - private clearVisible = false; - - pending = false; - +export class SelectAllBarViewModel { constructor( - host: SetupComponentHost, - private readonly gate: DerivedPropsGate, - private readonly selectService: SelectService, - private readonly count: CounterService, - private readonly enableSelectAll: boolean + private emitter: Emitter, + private state: { pending: boolean; visible: boolean; clearBtnVisible: boolean }, + private selectionTexts: { + clearSelectionButtonLabel: string; + selectedCountText: string; + }, + private selectAllTexts: { + selectAllLabel: string; + selectionStatus: string; + }, + private enableSelectAll: boolean ) { - host.add(this); - type PrivateMembers = "setClearVisible" | "setPending" | "hideBar" | "showBar"; - makeAutoObservable(this, { - setClearVisible: action, - setPending: action, - hideBar: action, - showBar: action - }); - } - - private get props(): DynamicProps { - return this.gate.props; - } - - private setClearVisible(value: boolean): void { - this.clearVisible = value; - } - - private setPending(value: boolean): void { - this.pending = value; - } - - private hideBar(): void { - this.barVisible = false; - this.clearVisible = false; - } - - private showBar(): void { - this.barVisible = true; - } - - private get total(): number { - return this.props.datasource.totalCount ?? 0; - } - - private get selectAllFormat(): string { - return this.props.selectAllTemplate?.value ?? "Select all %d rows in the data source"; - } - - private get selectAllText(): string { - return this.props.selectAllText?.value ?? "Select all rows in the data source"; - } - - private get allSelectedText(): string { - const str = this.props.allSelectedText?.value ?? "All %d rows selected."; - return str.replace("%d", `${this.count.selectedCount}`); - } - - private get isCurrentPageSelected(): boolean { - const selection = this.props.itemSelection; - - if (!selection || selection.type === "Single") return false; - - const pageIds = new Set(this.props.datasource.items?.map(item => item.id) ?? []); - const selectionSubArray = selection.selection.filter(item => pageIds.has(item.id)); - return selectionSubArray.length === pageIds.size && pageIds.size > 0; - } - - private get isAllItemsSelected(): boolean { - if (this.total > 0) return this.total === this.count.selectedCount; - - const { offset, limit, items = [], hasMoreItems } = this.gate.props.datasource; - const noMoreItems = typeof hasMoreItems === "boolean" && hasMoreItems === false; - const fullyLoaded = offset === 0 && limit >= items.length; - - return fullyLoaded && noMoreItems && items.length === this.count.selectedCount; + makeAutoObservable(this); } get selectAllLabel(): string { - if (this.total > 0) return this.selectAllFormat.replace("%d", `${this.total}`); - return this.selectAllText; + return this.selectAllTexts.selectAllLabel; } get clearSelectionLabel(): string { - return this.count.clearButtonLabel; + return this.selectionTexts.clearSelectionButtonLabel; } get selectionStatus(): string { - if (this.isAllItemsSelected) return this.allSelectedText; - return this.count.selectedCountText; + return this.selectAllTexts.selectionStatus; } get isBarVisible(): boolean { - return this.enableSelectAll && this.barVisible; + return this.enableSelectAll && this.state.visible; } get isClearVisible(): boolean { - return this.clearVisible; + return this.state.clearBtnVisible; } get isSelectAllDisabled(): boolean { - return this.pending; - } - - setup(): (() => void) | void { - if (!this.enableSelectAll) return; - - return reaction( - () => this.isCurrentPageSelected, - isCurrentPageSelected => { - if (isCurrentPageSelected === false) { - this.hideBar(); - } else if (this.isAllItemsSelected === false) { - this.showBar(); - } - } - ); + return this.state.pending; } onClear(): void { - this.selectService.clearSelection(); + this.emitter.emit("clear"); } - async onSelectAll(): Promise { - this.setPending(true); - try { - const { success } = await this.selectService.selectAllPages(); - this.setClearVisible(success); - } finally { - this.setPending(false); - } + onSelectAll(): void { + this.emitter.emit("startSelecting"); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts deleted file mode 100644 index 4476056573..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; - -export type SelectAllGateProps = { - datasource: ListValue; - itemSelection?: SelectionSingleValue | SelectionMultiValue; -}; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts index d191741e31..1d86ba4f7c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts @@ -1,32 +1,105 @@ -import { DatasourceService, ProgressService, SelectAllService } from "@mendix/widget-plugin-grid/main"; +import { DatasourceService, SelectAllService, TaskProgressService } from "@mendix/widget-plugin-grid/main"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; -import { Container } from "brandi"; -import { TOKENS } from "../../model/tokens"; -import { SelectAllGateProps } from "./SelectAllGateProps"; +import { Container, injected } from "brandi"; + +import { SelectAllFeature } from "@mendix/widget-plugin-grid/select-all/select-all.feature"; +import { selectAllEmitter, selectAllTextsStore } from "@mendix/widget-plugin-grid/select-all/select-all.model"; +import { SelectAllBarStore } from "@mendix/widget-plugin-grid/select-all/SelectAllBar.store"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { MainGateProps } from "../../../typings/MainGateProps"; +import { DatagridConfig } from "../../model/configs/Datagrid.config"; +import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../../model/tokens"; +import { SelectAllBarViewModel } from "./SelectAllBar.viewModel"; +import { SelectionProgressDialogViewModel } from "./SelectionProgressDialog.viewModel"; + +injected( + selectAllTextsStore, + SA_TOKENS.gate, + CORE.selection.selectedCount, + CORE.selection.selectedCounterTextsStore, + CORE.atoms.totalCount, + CORE.selection.isAllItemsSelected +); + +injected( + SelectAllBarViewModel, + SA_TOKENS.emitter, + SA_TOKENS.barStore, + CORE.selection.selectedCounterTextsStore, + SA_TOKENS.selectAllTextsStore, + SA_TOKENS.enableSelectAll +); + +injected( + SelectionProgressDialogViewModel, + CORE.setupService, + SA_TOKENS.gate, + SA_TOKENS.progressService, + SA_TOKENS.selectAllService +); + +injected( + SelectAllFeature, + CORE.setupService, + SA_TOKENS.emitter, + SA_TOKENS.selectAllService, + SA_TOKENS.barStore, + SA_TOKENS.progressService, + CORE.selection.isCurrentPageSelected, + CORE.selection.isAllItemsSelected +); + +injected(SelectAllService, SA_TOKENS.gate, DG.query, SA_TOKENS.emitter); export class SelectAllModule extends Container { id = `SelectAllModule@${generateUUID()}`; - init(props: SelectAllGateProps, root: Container): SelectAllModule { + constructor(root: Container) { + super(); this.extend(root); + this.bind(SA_TOKENS.barStore).toInstance(SelectAllBarStore).inSingletonScope(); + this.bind(SA_TOKENS.emitter).toInstance(selectAllEmitter).inSingletonScope(); + this.bind(DG.query).toInstance(DatasourceService).inSingletonScope(); + this.bind(SA_TOKENS.selectAllService).toInstance(SelectAllService).inSingletonScope(); + this.bind(SA_TOKENS.selectAllTextsStore).toInstance(selectAllTextsStore).inSingletonScope(); + this.bind(SA_TOKENS.selectAllBarVM).toInstance(SelectAllBarViewModel).inSingletonScope(); + this.bind(SA_TOKENS.selectionDialogVM).toInstance(SelectionProgressDialogViewModel).inSingletonScope(); + this.bind(SA_TOKENS.feature).toInstance(SelectAllFeature).inSingletonScope(); + } + + init(dependencies: { + props: MainGateProps; + mainGate: DerivedPropsGate; + progressSrv: TaskProgressService; + config: DatagridConfig; + }): SelectAllModule { + const { props, config, mainGate, progressSrv } = dependencies; - const gateProvider = new GateProvider(props); - this.setProps = props => gateProvider.setProps(props); + const ownGate = new GateProvider(props); + this.setProps = props => ownGate.setProps(props); - // Bind service deps - this.bind(TOKENS.selectAllGate).toConstant(gateProvider.gate); - this.bind(TOKENS.queryGate).toConstant(gateProvider.gate); - this.bind(TOKENS.query).toInstance(DatasourceService).inSingletonScope(); - this.bind(TOKENS.selectAllProgressService).toInstance(ProgressService).inSingletonScope(); + this.bind(CORE.config).toConstant(config); + // Bind main gate from main provider. + this.bind(CORE.mainGate).toConstant(mainGate); + this.bind(SA_TOKENS.progressService).toConstant(progressSrv); + this.bind(SA_TOKENS.gate).toConstant(ownGate.gate); + this.bind(DG.queryGate).toConstant(ownGate.gate); + this.bind(SA_TOKENS.enableSelectAll).toConstant(config.enableSelectAll); - // Finally bind select all service - this.bind(TOKENS.selectAllService).toInstance(SelectAllService).inSingletonScope(); + this.postInit(); return this; } - setProps = (_props: SelectAllGateProps): void => { + postInit(): void { + // Initialize feature + if (this.get(SA_TOKENS.enableSelectAll)) { + this.get(SA_TOKENS.feature); + } + } + + setProps = (_props: MainGateProps): void => { throw new Error(`${this.id} is not initialized yet`); }; } diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts index fdb34406e8..8e46db7ed0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts @@ -1,5 +1,5 @@ import { createInjectionHooks } from "brandi-react"; -import { TOKENS } from "../../model/tokens"; +import { SA_TOKENS } from "../../model/tokens"; -export const [useSelectAllBarViewModel] = createInjectionHooks(TOKENS.selectAllBarVM); -export const [useSelectionDialogViewModel] = createInjectionHooks(TOKENS.selectionDialogVM); +export const [useSelectAllBarViewModel] = createInjectionHooks(SA_TOKENS.selectAllBarVM); +export const [useSelectionDialogViewModel] = createInjectionHooks(SA_TOKENS.selectionDialogVM); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts index bfe4f153fc..519c6fe216 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts @@ -1,4 +1,4 @@ import { createInjectionHooks } from "brandi-react"; -import { TOKENS } from "../../model/tokens"; +import { DG_TOKENS } from "../../model/tokens"; -export const [useSelectionCounterViewModel] = createInjectionHooks(TOKENS.selectionCounterVM); +export const [useSelectionCounterViewModel] = createInjectionHooks(DG_TOKENS.selectionCounterVM); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts b/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts index 5a40e0d81b..0daaf02546 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts @@ -12,6 +12,7 @@ export interface DatagridConfig { selectionEnabled: boolean; selectorColumnEnabled: boolean; settingsStorageEnabled: boolean; + enableSelectAll: boolean; } export function datagridConfig(props: DatagridContainerProps): DatagridConfig { @@ -26,7 +27,8 @@ export function datagridConfig(props: DatagridContainerProps): DatagridConfig { selectAllCheckboxEnabled: props.showSelectAllToggle, selectionEnabled: isSelectionEnabled(props), selectorColumnEnabled: props.columnsHidable, - settingsStorageEnabled: isSettingsStorageEnabled(props) + settingsStorageEnabled: isSettingsStorageEnabled(props), + enableSelectAll: props.enableSelectAll }); } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts index cab8e70eac..1878ef2326 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -1,121 +1,124 @@ import { WidgetFilterAPI } 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 { itemCountAtom } from "@mendix/widget-plugin-grid/core/models/datasource.model"; import { emptyStateWidgetsAtom } from "@mendix/widget-plugin-grid/core/models/empty-state.model"; -import { DatasourceService, ProgressService, SelectionCounterViewModel } from "@mendix/widget-plugin-grid/main"; -import { ClosableGateProvider } from "@mendix/widget-plugin-mobx-kit/ClosableGateProvider"; +import { DatasourceService, TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; -import { Container } from "brandi"; -import { DatagridContainerProps } from "../../../typings/DatagridProps"; +import { Container, injected } from "brandi"; import { MainGateProps } from "../../../typings/MainGateProps"; -import { SelectAllBarViewModel } from "../../features/select-all/SelectAllBar.viewModel"; -import { SelectionProgressDialogViewModel } from "../../features/select-all/SelectionProgressDialog.viewModel"; +import { EmptyPlaceholderViewModel } from "../../features/empty-message/EmptyPlaceholder.viewModel"; +import { SelectAllModule } from "../../features/select-all/SelectAllModule.container"; import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore"; -import { DatagridConfig, datagridConfig } from "../configs/Datagrid.config"; -import { visibleColumnsCountAtom } from "../models/columns.model"; +import { DatagridConfig } from "../configs/Datagrid.config"; import { DatasourceParamsController } from "../services/DatasourceParamsController"; import { DerivedLoaderController } from "../services/DerivedLoaderController"; import { PaginationController } from "../services/PaginationController"; -import { TOKENS } from "../tokens"; +import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../tokens"; + +injected(ColumnGroupStore, CORE.setupService, CORE.mainGate, CORE.config, DG.filterHost); +injected(CombinedFilter, CORE.setupService, DG.combinedFilterConfig); +injected(DatasourceParamsController, CORE.setupService, DG.query, DG.combinedFilter, CORE.columnsStore); +injected(DatasourceService, CORE.setupService, DG.queryGate, DG.refreshInterval.optional); +injected(DerivedLoaderController, DG.query, DG.exportProgressService, CORE.columnsStore, DG.loaderConfig); +injected(EmptyPlaceholderViewModel, DG.emptyPlaceholderWidgets, CORE.atoms.itemCount, CORE.config); +injected(GridBasicData, CORE.mainGate); +injected(GridPersonalizationStore, CORE.setupService, CORE.mainGate, CORE.columnsStore, DG.filterHost); +injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query); +injected(WidgetFilterAPI, DG.parentChannelName, DG.filterHost); +injected(emptyStateWidgetsAtom, CORE.mainGate, CORE.atoms.itemCount); + +injected( + SelectionCounterViewModel, + CORE.selection.selectedCount, + CORE.selection.selectedCounterTextsStore, + DG.selectionCounterCfg.optional +); export class DatagridContainer extends Container { id = `DatagridContainer@${generateUUID()}`; - /** - * Setup container bindings. - * @remark Make sure not to bind things that already exist in root container. - */ - init(props: DatagridContainerProps, root: Container, selectAllModule: Container): DatagridContainer { + constructor(root: Container) { + super(); this.extend(root); - // Connect select all module - const selectAllService = selectAllModule.get(TOKENS.selectAllService); - const selectAllProgress = selectAllModule.get(TOKENS.selectAllProgressService); - // Bind select all service - this.bind(TOKENS.selectAllService).toConstant(selectAllService); - // Bind select all progress - this.bind(TOKENS.selectAllProgressService).toConstant(selectAllProgress); - - // Create main gate - this.bind(TOKENS.exportProgressService).toInstance(ProgressService).inSingletonScope(); - const exportProgress = this.get(TOKENS.exportProgressService); - const gateProvider = new ClosableGateProvider(props, function isLocked() { - return exportProgress.inProgress || selectAllProgress.inProgress; - }); - this.setProps = props => gateProvider.setProps(props); - - // Bind main gate - this.bind(TOKENS.mainGate).toConstant(gateProvider.gate); - this.bind(TOKENS.queryGate).toConstant(gateProvider.gate); - - // Bind config - const config = datagridConfig(props); - this.bind(TOKENS.config).toConstant(config); - - // Columns store - this.bind(TOKENS.columnsStore).toInstance(ColumnGroupStore).inSingletonScope(); - // Basic data store - this.bind(TOKENS.basicDate).toInstance(GridBasicData).inSingletonScope(); - - // Combined filter - this.bind(TOKENS.combinedFilter).toInstance(CombinedFilter).inSingletonScope(); - - // Export progress - this.bind(TOKENS.exportProgressService).toInstance(ProgressService).inSingletonScope(); - + this.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope(); + // Columns store + this.bind(CORE.columnsStore).toInstance(ColumnGroupStore).inSingletonScope(); + // Query service + this.bind(DG.query).toInstance(DatasourceService).inSingletonScope(); + // Pagination service + this.bind(DG.paginationService).toInstance(PaginationController).inSingletonScope(); + // Datasource params service + this.bind(DG.paramsService).toInstance(DatasourceParamsController).inSingletonScope(); // FilterAPI - this.bind(TOKENS.filterAPI).toInstance(WidgetFilterAPI).inSingletonScope(); - + this.bind(DG.filterAPI).toInstance(WidgetFilterAPI).inSingletonScope(); // Filter host - this.bind(TOKENS.filterHost).toInstance(CustomFilterHost).inSingletonScope(); - - // Datasource params service - this.bind(TOKENS.paramsService).toInstance(DatasourceParamsController).inSingletonScope(); - + this.bind(DG.filterHost).toInstance(CustomFilterHost).inSingletonScope(); + // Combined filter + this.bind(DG.combinedFilter).toInstance(CombinedFilter).inSingletonScope(); // Personalization service - this.bind(TOKENS.personalizationService).toInstance(GridPersonalizationStore).inSingletonScope(); + this.bind(DG.personalizationService).toInstance(GridPersonalizationStore).inSingletonScope(); + // Loader view model + this.bind(DG.loaderVM).toInstance(DerivedLoaderController).inSingletonScope(); + // Selection counter view model + this.bind(DG.selectionCounterVM).toInstance(SelectionCounterViewModel).inSingletonScope(); + // Empty placeholder + this.bind(DG.emptyPlaceholderVM).toInstance(EmptyPlaceholderViewModel).inSingletonScope(); + this.bind(DG.emptyPlaceholderWidgets).toInstance(emptyStateWidgetsAtom).inTransientScope(); + } - // Query service - this.bind(TOKENS.query).toInstance(DatasourceService).inSingletonScope(); + /** + * Setup container constants. If possible, declare all other bindings in the constructor. + * @remark Make sure not to bind things that already exist in root container. + */ + init(dependencies: { + props: MainGateProps; + config: DatagridConfig; + mainGate: DerivedPropsGate; + exportProgressService: TaskProgressService; + selectAllModule: SelectAllModule; + }): DatagridContainer { + const { props, config, mainGate, exportProgressService, selectAllModule } = dependencies; - // Pagination service - this.bind(TOKENS.paginationService).toInstance(PaginationController).inSingletonScope(); + // Main gate - // Events channel for child widgets - this.bind(TOKENS.parentChannelName).toConstant(config.filtersChannelName); + this.bind(CORE.mainGate).toConstant(mainGate); + this.bind(DG.queryGate).toConstant(mainGate); - // Loader view model - this.bind(TOKENS.loaderVM).toInstance(DerivedLoaderController).inSingletonScope(); + // Export progress service + this.bind(DG.exportProgressService).toConstant(exportProgressService); - // Selection counter view model - this.bind(TOKENS.selectionCounterVM).toInstance(SelectionCounterViewModel).inSingletonScope(); + // Config + this.bind(CORE.config).toConstant(config); - // Select all bar view model - this.bind(TOKENS.selectAllBarVM).toInstance(SelectAllBarViewModel).inSingletonScope(); + // Connect select all module + this.bind(SA_TOKENS.selectionDialogVM).toConstant(selectAllModule.get(SA_TOKENS.selectionDialogVM)); + this.bind(SA_TOKENS.selectAllBarVM).toConstant(selectAllModule.get(SA_TOKENS.selectAllBarVM)); - // Selection progress dialog view model - this.bind(TOKENS.selectionDialogVM).toInstance(SelectionProgressDialogViewModel).inSingletonScope(); + // Events channel for child widgets + this.bind(DG.parentChannelName).toConstant(config.filtersChannelName); // Bind refresh interval - this.bind(TOKENS.refreshInterval).toConstant(props.refreshInterval * 1000); + this.bind(DG.refreshInterval).toConstant(config.refreshIntervalMs); // Bind combined filter config - this.bind(TOKENS.combinedFilterConfig).toConstant({ + this.bind(DG.combinedFilterConfig).toConstant({ stableKey: props.name, - inputs: [this.get(TOKENS.filterHost), this.get(TOKENS.columnsStore)] + inputs: [this.get(DG.filterHost), this.get(CORE.columnsStore)] }); // Bind loader config - this.bind(TOKENS.loaderConfig).toConstant({ + this.bind(DG.loaderConfig).toConstant({ showSilentRefresh: props.refreshInterval > 1, refreshIndicator: props.refreshIndicator }); // Bind pagination config - this.bind(TOKENS.paginationConfig).toConstant({ + this.bind(DG.paginationConfig).toConstant({ pagination: props.pagination, showPagingButtons: props.showPagingButtons, showNumberOfRows: props.showNumberOfRows, @@ -123,15 +126,7 @@ export class DatagridContainer extends Container { }); // Bind selection counter position - this.bind(TOKENS.selectionCounterPosition).toConstant(props.selectionCounterPosition); - - // Bind select all enabled flag - this.bind(TOKENS.enableSelectAll).toConstant(props.enableSelectAll); - - // Atoms - this.bind(TOKENS.visibleColumnsCount).toInstance(visibleColumnsCountAtom).inTransientScope(); - this.bind(TOKENS.visibleRowCount).toInstance(itemCountAtom).inTransientScope(); - this.bind(TOKENS.emptyPlaceholderWidgets).toInstance(emptyStateWidgetsAtom).inTransientScope(); + this.bind(DG.selectionCounterCfg).toConstant({ position: props.selectionCounterPosition }); this.postInit(props, config); @@ -139,20 +134,16 @@ export class DatagridContainer extends Container { } /** Post init hook for final configuration. */ - private postInit(props: DatagridContainerProps, config: DatagridConfig): void { + private postInit(props: MainGateProps, config: DatagridConfig): void { // Make sure essential services are created upfront - this.get(TOKENS.paramsService); - this.get(TOKENS.paginationService); + this.get(DG.paramsService); + this.get(DG.paginationService); if (config.settingsStorageEnabled) { - this.get(TOKENS.personalizationService); + this.get(DG.personalizationService); } // Hydrate filters from props - this.get(TOKENS.combinedFilter).hydrate(props.datasource.filter); + this.get(DG.combinedFilter).hydrate(props.datasource.filter); } - - setProps = (_props: MainGateProps): void => { - throw new Error(`${this.id} is not initialized yet`); - }; } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts index f4c9e2ea37..b4cdcaef23 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts @@ -1,18 +1,70 @@ +import { + hasMoreItemsAtom, + isAllItemsPresentAtom, + itemCountAtom, + limitAtom, + offsetAtom, + totalCountAtom +} from "@mendix/widget-plugin-grid/core/models/datasource.model"; +import { + isAllItemsSelectedAtom, + isCurrentPageSelectedAtom, + selectedCountMultiAtom, + selectionCounterTextsStore +} from "@mendix/widget-plugin-grid/core/models/selection.model"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; -import { Container } from "brandi"; +import { Container, injected } from "brandi"; +import { visibleColumnsCountAtom } from "../models/columns.model"; import { DatagridSetupService } from "../services/DatagridSetup.service"; -import { TOKENS } from "../tokens"; +import { CORE_TOKENS as CORE } from "../tokens"; + +// datasource +injected(totalCountAtom, CORE.mainGate); +injected(itemCountAtom, CORE.mainGate); +injected(offsetAtom, CORE.mainGate); +injected(limitAtom, CORE.mainGate); +injected(hasMoreItemsAtom, CORE.mainGate); +injected(visibleColumnsCountAtom, CORE.columnsStore); +injected(isAllItemsPresentAtom, CORE.atoms.offset, CORE.atoms.hasMoreItems); + +// selection +injected( + isAllItemsSelectedAtom, + CORE.selection.selectedCount, + CORE.atoms.itemCount, + CORE.atoms.totalCount, + CORE.atoms.isAllItemsPresent +); +injected(isCurrentPageSelectedAtom, CORE.mainGate); +injected(selectedCountMultiAtom, CORE.mainGate); +injected(selectionCounterTextsStore, CORE.mainGate, CORE.selection.selectedCount); /** * Root container for bindings that can be shared down the hierarchy. - * Use only for bindings that needs to be shared across multiple containers. - * @remark Don't bind things that depend on props here. + * Declare only bindings that needs to be shared across multiple containers. + * @remark Don't bind constants or other things that depend on props here. */ export class RootContainer extends Container { id = `DatagridRootContainer@${generateUUID()}`; constructor() { super(); - this.bind(TOKENS.setupService).toInstance(DatagridSetupService).inSingletonScope(); + // The root setup host service + this.bind(CORE.setupService).toInstance(DatagridSetupService).inSingletonScope(); + + // datasource + this.bind(CORE.atoms.hasMoreItems).toInstance(hasMoreItemsAtom).inTransientScope(); + this.bind(CORE.atoms.itemCount).toInstance(itemCountAtom).inTransientScope(); + this.bind(CORE.atoms.limit).toInstance(limitAtom).inTransientScope(); + this.bind(CORE.atoms.offset).toInstance(offsetAtom).inTransientScope(); + this.bind(CORE.atoms.totalCount).toInstance(totalCountAtom).inTransientScope(); + this.bind(CORE.atoms.visibleColumnsCount).toInstance(visibleColumnsCountAtom).inTransientScope(); + this.bind(CORE.atoms.isAllItemsPresent).toInstance(isAllItemsPresentAtom).inTransientScope(); + + // selection + this.bind(CORE.selection.selectedCount).toInstance(selectedCountMultiAtom).inTransientScope(); + this.bind(CORE.selection.isCurrentPageSelected).toInstance(isCurrentPageSelectedAtom).inTransientScope(); + this.bind(CORE.selection.selectedCounterTextsStore).toInstance(selectionCounterTextsStore).inTransientScope(); + this.bind(CORE.selection.isAllItemsSelected).toInstance(isAllItemsSelectedAtom).inTransientScope(); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts index 9b8a0d3efa..b23babaf98 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -1,11 +1,11 @@ import { createInjectionHooks } from "brandi-react"; -import { TOKENS } from "../tokens"; +import { CORE_TOKENS as CORE, DG_TOKENS as DG } from "../tokens"; -export const [useBasicData] = createInjectionHooks(TOKENS.basicDate); -export const [useColumnsStore] = createInjectionHooks(TOKENS.columnsStore); -export const [useDatagridConfig] = createInjectionHooks(TOKENS.config); -export const [useDatagridFilterAPI] = createInjectionHooks(TOKENS.filterAPI); -export const [useExportProgressService] = createInjectionHooks(TOKENS.exportProgressService); -export const [useLoaderViewModel] = createInjectionHooks(TOKENS.loaderVM); -export const [useMainGate] = createInjectionHooks(TOKENS.mainGate); -export const [usePaginationService] = createInjectionHooks(TOKENS.paginationService); +export const [useBasicData] = createInjectionHooks(DG.basicDate); +export const [useColumnsStore] = createInjectionHooks(CORE.columnsStore); +export const [useDatagridConfig] = createInjectionHooks(CORE.config); +export const [useDatagridFilterAPI] = createInjectionHooks(DG.filterAPI); +export const [useExportProgressService] = createInjectionHooks(DG.exportProgressService); +export const [useLoaderViewModel] = createInjectionHooks(DG.loaderVM); +export const [useMainGate] = createInjectionHooks(CORE.mainGate); +export const [usePaginationService] = createInjectionHooks(DG.paginationService); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts index d82c8bf1fe..cdc6f073f4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts @@ -3,26 +3,46 @@ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { Container } from "brandi"; import { useEffect } from "react"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; +import { MainGateProps } from "../../../typings/MainGateProps"; import { SelectAllModule } from "../../features/select-all/SelectAllModule.container"; +import { datagridConfig } from "../configs/Datagrid.config"; import { DatagridContainer } from "../containers/Datagrid.container"; import { RootContainer } from "../containers/Root.container"; -import { TOKENS } from "../tokens"; +import { MainGateProvider } from "../services/MainGateProvider.service"; +import { CORE_TOKENS as CORE } from "../tokens"; export function useDatagridContainer(props: DatagridContainerProps): Container { - const [container, selectAllModule] = useConst(function init(): [DatagridContainer, SelectAllModule] { + const [container, selectAllModule, mainProvider] = useConst(function init(): [ + DatagridContainer, + SelectAllModule, + MainGateProvider + ] { const root = new RootContainer(); - const selectAllModule = new SelectAllModule().init(props, root); - const container = new DatagridContainer().init(props, root, selectAllModule); + const config = datagridConfig(props); + const mainProvider = new MainGateProvider(props); + const selectAllModule = new SelectAllModule(root).init({ + props, + config, + mainGate: mainProvider.gate, + progressSrv: mainProvider.selectAllProgress + }); + const container = new DatagridContainer(root).init({ + props, + config, + selectAllModule, + mainGate: mainProvider.gate, + exportProgressService: mainProvider.exportProgress + }); - return [container, selectAllModule]; + return [container, selectAllModule, mainProvider]; }); // Run setup hooks on mount - useSetup(() => container.get(TOKENS.setupService)); + useSetup(() => container.get(CORE.setupService)); // Push props through the gates useEffect(() => { - container.setProps(props); + mainProvider.setProps(props); selectAllModule.setProps(props); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts b/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts new file mode 100644 index 0000000000..01d098442a --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts @@ -0,0 +1,20 @@ +import { ProgressService, TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/main"; + +export class MainGateProvider extends GateProvider { + selectAllProgress: TaskProgressService; + exportProgress: TaskProgressService; + + constructor(props: T) { + super(props); + this.selectAllProgress = new ProgressService(); + this.exportProgress = new ProgressService(); + } + + setProps(props: T): void { + if (this.exportProgress.inProgress) return; + if (this.selectAllProgress.inProgress) return; + + super.setProps(props); + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index c970f1b089..8b4ecf3e3a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -1,115 +1,107 @@ -import { FilterAPI, WidgetFilterAPI } from "@mendix/widget-plugin-filtering/context"; +import { FilterAPI } from "@mendix/widget-plugin-filtering/context"; import { CombinedFilter, CombinedFilterConfig } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter"; import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; -import { itemCountAtom } from "@mendix/widget-plugin-grid/core/models/datasource.model"; -import { emptyStateWidgetsAtom } from "@mendix/widget-plugin-grid/core/models/empty-state.model"; +import { QueryService, SelectAllService, TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { SelectAllFeature } from "@mendix/widget-plugin-grid/select-all/select-all.feature"; import { - DatasourceService, - QueryService, - SelectAllService, - SelectionCounterViewModel, - TaskProgressService -} from "@mendix/widget-plugin-grid/main"; -import { ComputedAtom, DerivedPropsGate, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; -import { injected, token } from "brandi"; + BarStore, + ObservableSelectAllTexts, + SelectAllEvents +} from "@mendix/widget-plugin-grid/select-all/select-all.model"; +import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms"; +import { ComputedAtom, DerivedPropsGate, Emitter } from "@mendix/widget-plugin-mobx-kit/main"; +import { token } from "brandi"; import { ListValue } from "mendix"; import { ReactNode } from "react"; -import { SelectionCounterPositionEnum } from "../../typings/DatagridProps"; import { MainGateProps } from "../../typings/MainGateProps"; import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel"; import { SelectAllBarViewModel } from "../features/select-all/SelectAllBar.viewModel"; -import { SelectAllGateProps } from "../features/select-all/SelectAllGateProps"; import { SelectionProgressDialogViewModel } from "../features/select-all/SelectionProgressDialog.viewModel"; import { ColumnGroupStore } from "../helpers/state/ColumnGroupStore"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../helpers/state/GridPersonalizationStore"; import { DatasourceParamsController } from "../model/services/DatasourceParamsController"; import { DatagridConfig } from "./configs/Datagrid.config"; -import { visibleColumnsCountAtom } from "./models/columns.model"; +import { DatagridSetupService } from "./services/DatagridSetup.service"; import { DerivedLoaderController, DerivedLoaderControllerConfig } from "./services/DerivedLoaderController"; import { PaginationConfig, PaginationController } from "./services/PaginationController"; -/** Tokens to resolve dependencies from the container. Please keep in alphabetical order. */ -export const TOKENS = { - basicDate: token("GridBasicData"), +/** Tokens to resolve dependencies from the container. */ + +/** Core tokens shared across containers through root container. */ +export const CORE_TOKENS = { + atoms: { + hasMoreItems: token>("@computed:hasMoreItems"), + itemCount: token>("@computed:itemCount"), + limit: token>("@computed:limit"), + offset: token>("@computed:offset"), + totalCount: token>("@computed:totalCount"), + visibleColumnsCount: token>("@computed:visibleColumnsCount"), + isAllItemsPresent: token>("@computed:isAllItemsPresent") + }, columnsStore: token("ColumnGroupStore"), + + config: token("DatagridConfig"), + + mainGate: token>("MainGate"), + + selection: { + selectedCount: token>("@computed:selectedCount"), + isAllItemsSelected: token>("@computed:isAllItemsSelected"), + isCurrentPageSelected: token>("@computed:isCurrentPageSelected"), + selectedCounterTextsStore: token<{ + clearSelectionButtonLabel: string; + selectedCountText: string; + }>("@store:selectedCounterTextsStore") + }, + + setupService: token("DatagridSetupService") +}; + +/** Datagrid tokens. */ +export const DG_TOKENS = { + basicDate: token("GridBasicData"), + combinedFilter: token("CombinedFilter"), combinedFilterConfig: token("CombinedFilterKey"), - config: token("DatagridConfig"), + emptyPlaceholderVM: token("EmptyPlaceholderViewModel"), emptyPlaceholderWidgets: token>("@computed:emptyPlaceholder"), - enableSelectAll: token("enableSelectAll"), + exportProgressService: token("ExportProgressService"), + filterAPI: token("FilterAPI"), filterHost: token("FilterHost"), + loaderConfig: token("DatagridLoaderConfig"), loaderVM: token("DatagridLoaderViewModel"), - mainGate: token>("MainGate"), + paginationConfig: token("PaginationConfig"), paginationService: token("PaginationService"), - paramsService: token("DatagridParamsService"), + parentChannelName: token("parentChannelName"), + refreshInterval: token("refreshInterval"), + + paramsService: token("DatagridParamsService"), personalizationService: token("GridPersonalizationStore"), + query: token("QueryService"), queryGate: token>("GateForQueryService"), - refreshInterval: token("refreshInterval"), + + selectionCounterCfg: token<{ position: "top" | "bottom" | "off" }>("SelectionCounterConfig"), + selectionCounterVM: token("SelectionCounterViewModel") +}; + +/** "Select all" module tokens. */ +export const SA_TOKENS = { + barStore: token("SelectAllBarStore"), + emitter: token>("SelectAllEmitter"), + gate: token>("MainGateForSelectAllContainer"), + progressService: token("SelectAllProgressService"), + selectAllTextsStore: token("SelectAllTextsStore"), selectAllBarVM: token("SelectAllBarViewModel"), - selectAllGate: token>("GateForSelectAllService"), - selectAllProgressService: token("SelectAllProgressService"), selectAllService: token("SelectAllService"), - selectionCounterPosition: token("SelectionCounterPositionEnum"), - selectionCounterVM: token("SelectionCounterViewModel"), selectionDialogVM: token("SelectionProgressDialogViewModel"), - setupService: token("DatagridSetupHost"), - visibleColumnsCount: token>("@computed:visibleColumnsCount"), - visibleRowCount: token>("@computed:visibleRowCount") + enableSelectAll: token("enableSelectAllFeatureFlag"), + feature: token("SelectAllFeature") }; - -/** Inject dependencies */ - -injected(ColumnGroupStore, TOKENS.setupService, TOKENS.mainGate, TOKENS.config, TOKENS.filterHost); - -injected(GridBasicData, TOKENS.mainGate); - -injected(CombinedFilter, TOKENS.setupService, TOKENS.combinedFilterConfig); - -injected(WidgetFilterAPI, TOKENS.parentChannelName, TOKENS.filterHost); - -injected(DatasourceParamsController, TOKENS.setupService, TOKENS.query, TOKENS.combinedFilter, TOKENS.columnsStore); - -injected(GridPersonalizationStore, TOKENS.setupService, TOKENS.mainGate, TOKENS.columnsStore, TOKENS.filterHost); - -injected(PaginationController, TOKENS.setupService, TOKENS.paginationConfig, TOKENS.query); - -injected(DatasourceService, TOKENS.setupService, TOKENS.queryGate, TOKENS.refreshInterval.optional); - -injected(DerivedLoaderController, TOKENS.query, TOKENS.exportProgressService, TOKENS.columnsStore, TOKENS.loaderConfig); - -injected(SelectionCounterViewModel, TOKENS.mainGate, TOKENS.selectionCounterPosition); - -injected(SelectAllService, TOKENS.setupService, TOKENS.selectAllGate, TOKENS.query, TOKENS.selectAllProgressService); - -injected( - SelectAllBarViewModel, - TOKENS.setupService, - TOKENS.mainGate, - TOKENS.selectAllService, - TOKENS.selectionCounterVM, - TOKENS.enableSelectAll -); - -injected( - SelectionProgressDialogViewModel, - TOKENS.setupService, - TOKENS.mainGate, - TOKENS.selectAllProgressService, - TOKENS.selectAllService -); - -injected(EmptyPlaceholderViewModel, TOKENS.emptyPlaceholderWidgets, TOKENS.visibleColumnsCount, TOKENS.config); - -injected(visibleColumnsCountAtom, TOKENS.columnsStore); - -injected(itemCountAtom, TOKENS.mainGate); - -injected(emptyStateWidgetsAtom, TOKENS.mainGate, TOKENS.visibleRowCount); diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts index 2f08c4fc2a..ba1c3bbba6 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts @@ -3,28 +3,27 @@ import { DatagridContainerProps } from "./DatagridProps"; /** Type to declare props available through main gate. */ export type MainGateProps = Pick< DatagridContainerProps, - | "name" - | "datasource" - | "refreshInterval" - | "refreshIndicator" - | "itemSelection" + | "allSelectedText" + | "cancelSelectionLabel" + | "clearSelectionButtonLabel" | "columns" - | "configurationStorageType" - | "storeFiltersInPersonalization" | "configurationAttribute" + | "configurationStorageType" + | "datasource" + | "datasource" + | "emptyPlaceholder" + | "enableSelectAll" + | "itemSelection" + | "name" | "pageSize" | "pagination" - | "showPagingButtons" - | "showNumberOfRows" - | "clearSelectionButtonLabel" + | "refreshIndicator" + | "refreshInterval" + | "selectAllRowsLabel" | "selectAllTemplate" | "selectAllText" - | "itemSelection" - | "datasource" - | "allSelectedText" - | "selectAllRowsLabel" - | "cancelSelectionLabel" | "selectionCounterPosition" - | "enableSelectAll" - | "emptyPlaceholder" + | "showNumberOfRows" + | "showPagingButtons" + | "storeFiltersInPersonalization" >; diff --git a/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts b/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts index df88d3cd49..23592fe084 100644 --- a/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts +++ b/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts @@ -1,5 +1,5 @@ import { configure, observable } from "mobx"; -import { selectedCountMulti } from "../models/selection.model.js"; +import { selectedCountMultiAtom } from "../models/selection.model.js"; describe("selectedCountMulti", () => { configure({ @@ -7,30 +7,32 @@ describe("selectedCountMulti", () => { }); it("returns selection length when type is Multi", () => { - const gate = observable({ itemSelection: { type: "Multi", selection: [{ id: "1" }, { id: "2" }] } }); - const atom = selectedCountMulti(gate); + const gate = observable({ + props: { itemSelection: { type: "Multi" as const, selection: [{ id: "1" }, { id: "2" }] } } + }); + const atom = selectedCountMultiAtom(gate); expect(atom.get()).toBe(2); }); it("returns -1 when type is Single", () => { - const gate = observable({ itemSelection: { type: "Single", selection: [] } }); - const atom = selectedCountMulti(gate); + const gate = observable({ props: { itemSelection: { type: "Single" as const, selection: [] } } }); + const atom = selectedCountMultiAtom(gate); expect(atom.get()).toBe(-1); }); it("returns -1 when itemSelection is undefined", () => { - const gate = observable({}); - const atom = selectedCountMulti(gate); + const gate = observable({ props: {} }); + const atom = selectedCountMultiAtom(gate); expect(atom.get()).toBe(-1); }); it("updates reactively when selection changes", () => { - const gate = observable({ itemSelection: { type: "Multi", selection: [{ id: "1" }] } }); - const atom = selectedCountMulti(gate); + const gate = observable({ props: { itemSelection: { type: "Multi" as const, selection: [{ id: "1" }] } } }); + const atom = selectedCountMultiAtom(gate); expect(atom.get()).toBe(1); - gate.itemSelection.selection.push({ id: "2" }); + gate.props.itemSelection.selection.push({ id: "2" }); expect(atom.get()).toBe(2); }); }); diff --git a/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts index 7c2cfe811f..0a023e8e0b 100644 --- a/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts +++ b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts @@ -2,16 +2,22 @@ import { atomFactory, ComputedAtom, DerivedPropsGate } from "@mendix/widget-plug import { DynamicValue } from "mendix"; import { computed, observable } from "mobx"; +type Item = { id: string }; +type Selection = { type: "Single" } | { type: "Multi"; selection: Item[] }; + /** * Returns selected count in multi-selection mode and -1 otherwise. * @injectable */ -export function selectedCountMulti(gate: { - itemSelection?: { type: string; selection: { length: number } }; -}): ComputedAtom { +export function selectedCountMultiAtom( + gate: DerivedPropsGate<{ + itemSelection?: Selection; + }> +): ComputedAtom { return computed(() => { - if (gate.itemSelection?.type === "Multi") { - return gate.itemSelection.selection.length; + const { itemSelection } = gate.props; + if (itemSelection?.type === "Multi") { + return itemSelection.selection.length; } return -1; }); @@ -43,8 +49,6 @@ export const isAllItemsSelectedAtom = atomFactory( isAllItemsSelected ); -type Item = { id: string }; - /** Return true if all items on current page selected. */ export function isCurrentPageSelected(selection: Item[], items: Item[]): boolean { const pageIds = new Set(items.map(item => item.id)); @@ -58,7 +62,7 @@ export function isCurrentPageSelected(selection: Item[], items: Item[]): boolean */ export function isCurrentPageSelectedAtom( gate: DerivedPropsGate<{ - itemSelection?: { type: "Single" } | { type: "Multi"; selection: Item[] }; + itemSelection?: Selection; datasource: { items?: Item[] }; }> ): ComputedAtom { @@ -78,7 +82,7 @@ interface ObservableSelectorTexts { selectedCountText: string; } -export function selectedCounterTextsStore( +export function selectionCounterTextsStore( gate: DerivedPropsGate<{ clearSelectionButtonLabel?: DynamicValue; selectedCountTemplateSingular?: DynamicValue; diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts index 50ab21528a..974e61b7ec 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts @@ -2,7 +2,7 @@ import { DerivedPropsGate, Emitter } from "@mendix/widget-plugin-mobx-kit/main"; import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { action, computed, makeObservable, observable, when } from "mobx"; import { QueryService } from "../interfaces/QueryService"; -import { ServiceEvents } from "./select-all.model"; +import { SelectAllEvents } from "./select-all.model"; interface DynamicProps { itemSelection?: SelectionMultiValue | SelectionSingleValue; @@ -16,7 +16,7 @@ export class SelectAllService { constructor( private gate: DerivedPropsGate, private query: QueryService, - private progress: Emitter + private progress: Emitter ) { type PrivateMembers = "locked"; makeObservable(this, { @@ -134,6 +134,8 @@ export class SelectAllService { performance.mark("SelectAll_End"); const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); console.debug(`Data grid 2: 'select all' took ${(measure1.duration / 1000).toFixed(2)} seconds.`); + + this.progress.emit("done", { success }); // eslint-disable-next-line no-unsafe-finally return { success }; } diff --git a/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts b/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts index 183d6bb42b..3e8f02d5ba 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts @@ -1,22 +1,34 @@ -import { ComputedAtom, disposeBatch, Emitter, SetupComponent } from "@mendix/widget-plugin-mobx-kit/main"; +import { + ComputedAtom, + disposeBatch, + Emitter, + SetupComponent, + SetupComponentHost +} from "@mendix/widget-plugin-mobx-kit/main"; +import { TaskProgressService } from "../main"; import { BarStore, SelectAllEvents, SelectService, setupBarStore, + setupProgressService, setupSelectService, setupVisibilityEvents } from "./select-all.model"; export class SelectAllFeature implements SetupComponent { constructor( + host: SetupComponentHost, private emitter: Emitter, private service: SelectService, private store: BarStore, + private progress: TaskProgressService, private isCurrentPageSelected: ComputedAtom, private isAllSelected: ComputedAtom - ) {} + ) { + host.add(this); + } setup(): () => void { const [add, disposeAll] = disposeBatch(); @@ -24,6 +36,7 @@ export class SelectAllFeature implements SetupComponent { add(setupBarStore(this.store, this.emitter)); add(setupSelectService(this.service, this.emitter)); add(setupVisibilityEvents(this.isCurrentPageSelected, this.isAllSelected, this.emitter)); + add(setupProgressService(this.progress, this.emitter)); return disposeAll; } diff --git a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts index 8adcddbb28..f2d2adea55 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts @@ -33,7 +33,7 @@ export function selectAllEmitter(): Emitter { return createEmitter(); } -interface ObservableSelectAllTexts { +export interface ObservableSelectAllTexts { selectionStatus: string; selectAllLabel: string; } diff --git a/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts index 88bb7ec9a3..7719ceb394 100644 --- a/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts +++ b/packages/shared/widget-plugin-grid/src/selection-counter/SelectionCounter.viewModel-atoms.ts @@ -9,18 +9,18 @@ export class SelectionCounterViewModel { clearSelectionButtonLabel: string; selectedCountText: string; }, - private position: "top" | "bottom" | "off" + private options: { position: "top" | "bottom" | "off" } = { position: "top" } ) { makeAutoObservable(this); } get isTopCounterVisible(): boolean { - if (this.position !== "top") return false; + if (this.options.position !== "top") return false; return this.selected.get() > 0; } get isBottomCounterVisible(): boolean { - if (this.position !== "bottom") return false; + if (this.options.position !== "bottom") return false; return this.selected.get() > 0; } diff --git a/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts b/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts index c5e607546c..a53f5d1ae8 100644 --- a/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts +++ b/packages/shared/widget-plugin-grid/src/selection-counter/__tests__/SelectionCounter.viewModel.spec.ts @@ -6,7 +6,7 @@ describe("SelectionCounterViewModel", () => { it("returns value from selected atom", () => { const selected = computed(() => 5); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "5 items selected" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "top"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "top" }); expect(viewModel.selectedCount).toBe(5); }); @@ -14,7 +14,7 @@ describe("SelectionCounterViewModel", () => { it("updates reactively when atom changes", () => { const selectedBox = observable.box(3); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selectedBox, texts, "top"); + const viewModel = new SelectionCounterViewModel(selectedBox, texts, { position: "top" }); expect(viewModel.selectedCount).toBe(3); @@ -27,7 +27,7 @@ describe("SelectionCounterViewModel", () => { it("returns value from texts object", () => { const selected = computed(() => 5); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "5 items selected" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "top"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "top" }); expect(viewModel.selectedCountText).toBe("5 items selected"); }); @@ -37,7 +37,7 @@ describe("SelectionCounterViewModel", () => { it("returns value from texts object", () => { const selected = computed(() => 0); const texts = { clearSelectionButtonLabel: "Clear selection", selectedCountText: "" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "top"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "top" }); expect(viewModel.clearButtonLabel).toBe("Clear selection"); }); @@ -47,7 +47,7 @@ describe("SelectionCounterViewModel", () => { it("returns true when position is top and selectedCount > 0", () => { const selected = computed(() => 5); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "top"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "top" }); expect(viewModel.isTopCounterVisible).toBe(true); }); @@ -55,7 +55,7 @@ describe("SelectionCounterViewModel", () => { it("returns false when position is top but selectedCount is 0", () => { const selected = computed(() => 0); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "top"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "top" }); expect(viewModel.isTopCounterVisible).toBe(false); }); @@ -63,7 +63,7 @@ describe("SelectionCounterViewModel", () => { it("returns false when position is not top", () => { const selected = computed(() => 5); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "bottom"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "bottom" }); expect(viewModel.isTopCounterVisible).toBe(false); }); @@ -73,7 +73,7 @@ describe("SelectionCounterViewModel", () => { it("returns true when position is bottom and selectedCount > 0", () => { const selected = computed(() => 5); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "bottom"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "bottom" }); expect(viewModel.isBottomCounterVisible).toBe(true); }); @@ -81,7 +81,7 @@ describe("SelectionCounterViewModel", () => { it("returns false when position is bottom but selectedCount is 0", () => { const selected = computed(() => 0); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "bottom"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "bottom" }); expect(viewModel.isBottomCounterVisible).toBe(false); }); @@ -89,7 +89,7 @@ describe("SelectionCounterViewModel", () => { it("returns false when position is not bottom", () => { const selected = computed(() => 5); const texts = { clearSelectionButtonLabel: "Clear", selectedCountText: "text" }; - const viewModel = new SelectionCounterViewModel(selected, texts, "top"); + const viewModel = new SelectionCounterViewModel(selected, texts, { position: "top" }); expect(viewModel.isBottomCounterVisible).toBe(false); }); From 45dd033b9e73e47af8aa354f2660c000c7e327ad Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:34:56 +0100 Subject: [PATCH 5/7] style: fix comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../datagrid-web/src/model/containers/Root.container.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts index b4cdcaef23..277cf65a7c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts @@ -42,7 +42,7 @@ injected(selectionCounterTextsStore, CORE.mainGate, CORE.selection.selectedCount /** * Root container for bindings that can be shared down the hierarchy. * Declare only bindings that needs to be shared across multiple containers. - * @remark Don't bind constants or other things that depend on props here. + * @remark Don't bind constants or directly prop-dependent values here. Prop-derived atoms/stores via dependency injection are acceptable. */ export class RootContainer extends Container { id = `DatagridRootContainer@${generateUUID()}`; From 3484b675de4fa76ddea490add4a1888e3525e8d7 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:37:42 +0100 Subject: [PATCH 6/7] style: fix comments & type --- .../src/model/services/MainGateProvider.service.ts | 4 ++++ .../pluggableWidgets/datagrid-web/typings/MainGateProps.ts | 1 - .../widget-plugin-grid/src/select-all/SelectAll.service.ts | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts b/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts index 01d098442a..99552b2222 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts @@ -11,6 +11,10 @@ export class MainGateProvider extends GateProvider { this.exportProgress = new ProgressService(); } + /** + * @remark + * To avoid unwanted UI rerenders, we block prop updates during the "select all" action or export. + */ setProps(props: T): void { if (this.exportProgress.inProgress) return; if (this.selectAllProgress.inProgress) return; diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts index ba1c3bbba6..de4af19873 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts @@ -10,7 +10,6 @@ export type MainGateProps = Pick< | "configurationAttribute" | "configurationStorageType" | "datasource" - | "datasource" | "emptyPlaceholder" | "enableSelectAll" | "itemSelection" diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts index 974e61b7ec..dd88e9d319 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAll.service.ts @@ -118,15 +118,15 @@ export class SelectAllService { console.error(error); } } finally { - // Restore init view - // This step should be done before loadend to avoid UI flickering + // Restore init view. This step should be done before loadend to avoid UI flickering. await this.query.fetchPage({ limit: initLimit, offset: initOffset }); + // Reload selection to make sure setSelection is working as expected. await this.reloadSelection(); + this.progress.emit("loadend"); - // Reload selection to make sure setSelection is working as expected. this.selection?.setSelection(success ? allItems : initSelection); this.locked = false; this.abortController = undefined; From 5b9717599bc5adbb841110cd66ea43451db8b2bd Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:47:25 +0100 Subject: [PATCH 7/7] fix: update test types --- .../datagrid-web/src/components/__tests__/Table.spec.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx index 8f9f717f6f..c0d379ab1e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -137,8 +137,7 @@ describe.skip("Table", () => { it("renders the structure correctly with empty placeholder", () => { const component = renderWithRootContext({ - ...mockWidgetProps(), - emptyPlaceholderRenderer: renderWrapper => renderWrapper(
) + ...mockWidgetProps() }); expect(component.asFragment()).toMatchSnapshot();