From 0e5d7220eba5acee7fa96b5382bc95af97342330 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 01/21] 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 7a987a6e0e8d6ed08a6214968b7a113b4ffc7b50 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 02/21] 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 ec588fa1c6becdbec6a0da48e5bf4878d830b083 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 03/21] 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 53fe86533b4f7c4c968ddec834e21515ba3ff2cc 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 04/21] 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 840e482af90d05ce5629ef1209bd3983f4acc5ac 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 05/21] 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 af826255f696cc5c3c63dd0372f26e03c2119dd7 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 06/21] 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 1602b82ba827c5ee7d65df5362ea21c5dbce11e8 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 07/21] 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(); From 763553cd152a501214c5600f3be79402bf09d1dd Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:09:30 +0100 Subject: [PATCH 08/21] refactor: rewrite dg pagination --- .../datagrid-web/src/Datagrid.tsx | 27 +------- .../datagrid-web/src/components/GridBody.tsx | 27 ++++---- .../src/components/Pagination.tsx | 25 +++++++ .../src/components/RowsRenderer.tsx | 1 - .../datagrid-web/src/components/Widget.tsx | 65 ++----------------- .../src/components/WidgetFooter.tsx | 29 +++++---- .../src/components/WidgetHeader.tsx | 33 +++++++--- .../src/components/WidgetHeaderContext.tsx | 33 ---------- .../src/components/WidgetTopBar.tsx | 22 +++---- .../src/helpers/SelectActionHelper.ts | 12 ++-- .../datagrid-web/src/helpers/root-context.ts | 4 +- .../src/model/configs/Datagrid.config.ts | 14 ++-- .../model/containers/Datagrid.container.ts | 18 ++++- .../src/model/hooks/injection-hooks.ts | 1 + .../model/services/PaginationController.ts | 16 ++++- .../model/services/SelectionGate.service.ts | 20 ++++++ .../datagrid-web/src/model/tokens.ts | 17 +++-- .../datagrid-web/src/utils/test-utils.tsx | 7 -- .../datagrid-web/typings/MainGateProps.ts | 1 + .../src/interfaces/MultiSelectionService.ts | 23 +++++++ .../src/interfaces/SelectionDynamicProps.ts | 7 ++ .../src/interfaces/SelectionHelperService.ts | 4 ++ .../src/interfaces/SingleSelectionService.ts | 8 +++ .../shared/widget-plugin-grid/src/main.ts | 4 ++ .../src/selection/context.ts | 30 +++------ .../src/selection/createSelectionHelper.ts | 61 +++++++++++++++++ .../src/selection/helpers.ts | 10 +-- .../src/selection/select-action-handler.ts | 4 +- .../widget-plugin-mobx-kit/src/MappedGate.ts | 16 +++++ .../shared/widget-plugin-mobx-kit/src/main.ts | 1 + 30 files changed, 320 insertions(+), 220 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx delete mode 100644 packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/services/SelectionGate.service.ts create mode 100644 packages/shared/widget-plugin-grid/src/interfaces/MultiSelectionService.ts create mode 100644 packages/shared/widget-plugin-grid/src/interfaces/SelectionDynamicProps.ts create mode 100644 packages/shared/widget-plugin-grid/src/interfaces/SelectionHelperService.ts create mode 100644 packages/shared/widget-plugin-grid/src/interfaces/SingleSelectionService.ts create mode 100644 packages/shared/widget-plugin-grid/src/selection/createSelectionHelper.ts create mode 100644 packages/shared/widget-plugin-mobx-kit/src/MappedGate.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 49cfaaa2aa..3cfb003e7e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -1,6 +1,5 @@ import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; -import { useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; 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"; @@ -9,7 +8,6 @@ import { ReactElement, useCallback, useMemo } from "react"; import { DatagridContainerProps } from "../typings/DatagridProps"; import { Cell } from "./components/Cell"; import { Widget } from "./components/Widget"; -import { WidgetHeaderContext } from "./components/WidgetHeaderContext"; import { useDataExport } from "./features/data-export/useDataExport"; import { useCellEventsController } from "./features/row-interaction/CellEventsController"; import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; @@ -21,26 +19,20 @@ import { useExportProgressService, useLoaderViewModel, useMainGate, - usePaginationService + useSelectionHelper } from "./model/hooks/injection-hooks"; import { useDatagridContainer } from "./model/hooks/useDatagridContainer"; const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { const gate = useMainGate(); const columnsStore = useColumnsStore(); - const paginationService = usePaginationService(); const exportProgress = useExportProgressService(); const loaderVM = useLoaderViewModel(); const items = gate.props.datasource.items ?? []; const [abortExport] = useDataExport(props, columnsStore, exportProgress); - const selectionHelper = useSelectionHelper( - gate.props.itemSelection, - gate.props.datasource, - props.onSelectionChange, - props.keepSelection ? "always keep" : "always clear" - ); + const selectionHelper = useSelectionHelper(); const selectActionHelper = useSelectActionHelper(props, selectionHelper); @@ -92,27 +84,14 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => { [columnsStore.columnFilters] )} headerTitle={props.filterSectionTitle?.value} - headerContent={ - props.filtersPlaceholder && ( - - {props.filtersPlaceholder} - - ) - } - hasMoreItems={props.datasource.hasMoreItems ?? false} + headerContent={props.filtersPlaceholder} headerWrapperRenderer={useCallback((_columnIndex: number, header: ReactElement) => header, [])} id={useMemo(() => `DataGrid${generateUUID()}`, [])} numberOfItems={props.datasource.totalCount} onExportCancel={abortExport} - page={paginationService.currentPage} - pageSize={props.pageSize} paginationType={props.pagination} loadMoreButtonCaption={props.loadMoreButtonCaption?.value} - paging={paginationService.showPagination} - pagingPosition={props.pagingPosition} - showPagingButtons={props.showPagingButtons} rowClass={useCallback((value: any) => props.rowClass?.get(value)?.value ?? "", [props.rowClass])} - setPage={paginationService.setPage} styles={props.style} exporting={exportProgress.inProgress} processedRows={exportProgress.loaded} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx index b9c3e76bb1..d30322478c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx @@ -1,9 +1,10 @@ +import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; import classNames from "classnames"; -import { Fragment, ReactElement, ReactNode } from "react"; -import { LoadingTypeEnum, PaginationEnum } from "../../typings/DatagridProps"; -import { SpinnerLoader } from "./loader/SpinnerLoader"; +import { Fragment, ReactElement, ReactNode, useCallback } from "react"; +import { LoadingTypeEnum } from "../../typings/DatagridProps"; +import { usePaginationService } from "../model/hooks/injection-hooks"; import { RowSkeletonLoader } from "./loader/RowSkeletonLoader"; -import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; +import { SpinnerLoader } from "./loader/SpinnerLoader"; interface Props { className?: string; @@ -14,30 +15,30 @@ interface Props { columnsHidable: boolean; columnsSize: number; rowsSize: number; - pageSize: number; - pagination: PaginationEnum; - hasMoreItems: boolean; - setPage?: (update: (page: number) => number) => void; } export function GridBody(props: Props): ReactElement { - const { children, pagination, hasMoreItems, setPage } = props; + const { children } = props; + + const paging = usePaginationService(); + const pageSize = paging.pageSize; + const setPage = useCallback((cb: (n: number) => number) => paging.setPage(cb), [paging]); - const isInfinite = pagination === "virtualScrolling"; + const isInfinite = paging.pagination === "virtualScrolling"; const [trackScrolling, bodySize, containerRef] = useInfiniteControl({ - hasMoreItems, + hasMoreItems: paging.hasMoreItems, isInfinite, setPage }); const content = (): ReactElement => { if (props.isFirstLoad) { - return 0 ? props.rowsSize : props.pageSize} />; + return 0 ? props.rowsSize : pageSize} />; } return ( {children} - {props.isFetchingNextBatch && } + {props.isFetchingNextBatch && } ); }; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx new file mode 100644 index 0000000000..fb8200c6d8 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/Pagination.tsx @@ -0,0 +1,25 @@ +import { Pagination as PaginationComponent } from "@mendix/widget-plugin-grid/components/Pagination"; +import { observer } from "mobx-react-lite"; +import { ReactNode } from "react"; +import { usePaginationService } from "../model/hooks/injection-hooks"; + +export const Pagination = observer(function Pagination(): ReactNode { + const paging = usePaginationService(); + + if (!paging.paginationVisible) return null; + + return ( + paging.setPage(page)} + nextPage={() => paging.setPage(n => n + 1)} + numberOfItems={paging.totalCount} + page={paging.currentPage} + pageSize={paging.pageSize} + showPagingButtons={paging.showPagingButtons} + previousPage={() => paging.setPage(n => n - 1)} + pagination={paging.pagination} + /> + ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx b/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx index 5e80900972..710401a460 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/RowsRenderer.tsx @@ -14,7 +14,6 @@ interface RowsRendererProps { eventsController: EventsController; focusController: FocusTargetController; interactive: boolean; - pageSize: number; preview: boolean; rowClass?: (item: ObjectItem) => string; rows: ObjectItem[]; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 39604e653a..7193c56166 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -1,15 +1,9 @@ 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 { ListActionValue, ObjectItem } from "mendix"; import { observer } from "mobx-react-lite"; import { CSSProperties, Fragment, ReactElement, ReactNode } from "react"; -import { - LoadingTypeEnum, - PaginationEnum, - PagingPositionEnum, - ShowPagingButtonsEnum -} from "../../typings/DatagridProps"; +import { LoadingTypeEnum, PaginationEnum } from "../../typings/DatagridProps"; import { EmptyPlaceholder } from "../features/empty-message/EmptyPlaceholder"; import { SelectAllBar } from "../features/select-all/SelectAllBar"; @@ -40,28 +34,19 @@ export interface WidgetProps ReactElement, columnIndex: number) => ReactElement; - hasMoreItems: boolean; headerContent?: ReactNode; headerTitle?: string; headerWrapperRenderer: (columnIndex: number, header: ReactElement) => ReactElement; id: string; numberOfItems?: number; onExportCancel?: () => void; - page: number; paginationType: PaginationEnum; loadMoreButtonCaption?: string; - pageSize: number; - paging: boolean; - pagingPosition: PagingPositionEnum; - showPagingButtons: ShowPagingButtonsEnum; - preview?: boolean; processedRows: number; rowClass?: (item: T) => string; - setPage?: (computePage: (prevPage: number) => number) => void; styles?: CSSProperties; rowAction?: ListActionValue; - showSelectAllToggle?: boolean; isFirstLoad: boolean; isFetchingNextBatch: boolean; loadingType: LoadingTypeEnum; @@ -116,43 +101,16 @@ const Main = observer((props: WidgetProps): ReactElemen CellComponent, columnsHidable, data: rows, - hasMoreItems, headerContent, headerTitle, loadMoreButtonCaption, - numberOfItems, - page, - pageSize, - paginationType, - paging, - pagingPosition, showRefreshIndicator, selectActionHelper, - setPage, visibleColumns } = props; const basicData = useBasicData(); - const showHeader = !!headerContent; - const showTopBarPagination = paging && (pagingPosition === "top" || pagingPosition === "both"); - const showFooterPagination = paging && (pagingPosition === "bottom" || pagingPosition === "both"); - - const pagination = paging ? ( - setPage && setPage(() => page)} - nextPage={() => setPage && setPage(prev => prev + 1)} - numberOfItems={numberOfItems} - page={page} - pageSize={pageSize} - showPagingButtons={props.showPagingButtons} - previousPage={() => setPage && setPage(prev => prev - 1)} - pagination={paginationType} - /> - ) : null; - const cssGridStyles = gridStyle(visibleColumns, { selectItemColumn: selectActionHelper.showCheckboxColumn, visibilitySelectorColumn: columnsHidable @@ -162,8 +120,8 @@ const Main = observer((props: WidgetProps): ReactElemen return ( - - {showHeader && {headerContent}} + + (props: WidgetProps): ReactElemen headerWrapperRenderer={props.headerWrapperRenderer} id={props.id} isLoading={props.columnsLoading} - preview={props.preview} + preview={false} /> {showRefreshIndicator ? : null} @@ -194,13 +152,9 @@ const Main = observer((props: WidgetProps): ReactElemen columnsHidable={columnsHidable} columnsSize={visibleColumns.length} rowsSize={rows.length} - pageSize={pageSize} - pagination={props.paginationType} - hasMoreItems={hasMoreItems} - setPage={setPage} > (props: WidgetProps): ReactElemen selectActionHelper={selectActionHelper} focusController={props.focusController} eventsController={props.cellEventsController} - pageSize={props.pageSize} /> - + ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 161cde501f..c8a546e060 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -1,42 +1,45 @@ import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; -import { PaginationEnum } from "../../typings/DatagridProps"; +import { ReactElement } from "react"; import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; +import { useDatagridConfig, usePaginationService } from "../model/hooks/injection-hooks"; +import { Pagination } from "./Pagination"; type WidgetFooterProps = { - pagination: ReactNode; - paginationType: PaginationEnum; loadMoreButtonCaption?: string; - hasMoreItems: boolean; - setPage?: (computePage: (prevPage: number) => number) => void; -} & ComponentPropsWithoutRef<"div">; +}; export const WidgetFooter = observer(function WidgetFooter(props: WidgetFooterProps): ReactElement | null { - const { pagination, paginationType, loadMoreButtonCaption, hasMoreItems, setPage, ...rest } = props; + const config = useDatagridConfig(); + const paging = usePaginationService(); + const { loadMoreButtonCaption } = props; const selectionCounterVM = useSelectionCounterViewModel(); return ( -
+
- {hasMoreItems && paginationType === "loadMore" && ( +
- )} -
{pagination}
+
+
+ + + +
); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx index f55c0db551..2cb3859f91 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeader.tsx @@ -1,19 +1,34 @@ -import { ComponentPropsWithoutRef, ReactElement } from "react"; +import { getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; +import { getGlobalSelectionContext, useCreateSelectionContextValue } from "@mendix/widget-plugin-grid/selection"; +import { PropsWithChildren, ReactElement, ReactNode } from "react"; +import { useDatagridFilterAPI, useSelectionHelper } from "../model/hooks/injection-hooks"; type WidgetHeaderProps = { headerTitle?: string; -} & ComponentPropsWithoutRef<"div">; + headerContent?: ReactNode; +}; -export function WidgetHeader(props: WidgetHeaderProps): ReactElement | null { - const { children, headerTitle, ...rest } = props; +const Selection = getGlobalSelectionContext(); +const FilterAPI = getGlobalFilterContextObject(); - if (!children) { - return null; - } +function HeaderContainer(props: PropsWithChildren): ReactElement { + const filterAPI = useDatagridFilterAPI(); + const selectionContext = useCreateSelectionContextValue(useSelectionHelper()); + return ( + + {props.children} + + ); +} + +export function WidgetHeader(props: WidgetHeaderProps): ReactNode { + const { headerContent, headerTitle } = props; + + if (!headerContent) return null; return ( -
- {children} +
+ {headerContent}
); } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx deleted file mode 100644 index 794f86029f..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetHeaderContext.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { getGlobalFilterContextObject } from "@mendix/widget-plugin-filtering/context"; -import { - getGlobalSelectionContext, - SelectionHelper, - useCreateSelectionContextValue -} from "@mendix/widget-plugin-grid/selection"; -import { memo, ReactElement, ReactNode } from "react"; -import { useDatagridFilterAPI } from "../model/hooks/injection-hooks"; - -interface WidgetHeaderContextProps { - children?: ReactNode; - selectionHelper?: SelectionHelper; -} - -const SelectionContext = getGlobalSelectionContext(); -const FilterContext = getGlobalFilterContextObject(); - -function HeaderContainer(props: WidgetHeaderContextProps): ReactElement { - const filterAPI = useDatagridFilterAPI(); - const selectionContext = useCreateSelectionContextValue(props.selectionHelper); - return ( - - {props.children} - - ); -} - -const component = memo(HeaderContainer); - -component.displayName = "WidgetHeaderContext"; - -// Override NamedExoticComponent type -export const WidgetHeaderContext = component as (props: WidgetHeaderContextProps) => ReactElement; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx index cdf48e1cd3..390194bc0c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetTopBar.tsx @@ -1,27 +1,27 @@ import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; +import { ReactElement } from "react"; import { SelectionCounter } from "../features/selection-counter/SelectionCounter"; import { useSelectionCounterViewModel } from "../features/selection-counter/injection-hooks"; +import { useDatagridConfig } from "../model/hooks/injection-hooks"; +import { Pagination } from "./Pagination"; -type WidgetTopBarProps = { - pagination: ReactNode; -} & ComponentPropsWithoutRef<"div">; - -export const WidgetTopBar = observer(function WidgetTopBar(props: WidgetTopBarProps): ReactElement { - const { pagination, ...rest } = props; - const selectionCounterVM = useSelectionCounterViewModel(); +export const WidgetTopBar = observer(function WidgetTopBar(): ReactElement { + const config = useDatagridConfig(); + const selectionCounter = useSelectionCounterViewModel(); return ( -
+
- +
- {pagination} + + +
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts index 9b4b28a056..c14d8492b1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts @@ -1,10 +1,6 @@ +import { SelectionHelperService } from "@mendix/widget-plugin-grid/main"; +import { SelectActionHandler, SelectionMode, WidgetSelectionProperty } from "@mendix/widget-plugin-grid/selection"; import { useMemo } from "react"; -import { - SelectActionHandler, - SelectionHelper, - SelectionMode, - WidgetSelectionProperty -} from "@mendix/widget-plugin-grid/selection"; import { DatagridContainerProps, DatagridPreviewProps, ItemSelectionMethodEnum } from "../../typings/DatagridProps"; export type SelectionMethod = "rowClick" | "checkbox" | "none"; @@ -15,7 +11,7 @@ export class SelectActionHelper extends SelectActionHandler { constructor( selection: WidgetSelectionProperty, - selectionHelper: SelectionHelper | undefined, + selectionHelper: SelectionHelperService | undefined, _selectionMethod: ItemSelectionMethodEnum, _showSelectAllToggle: boolean, pageSize: number, @@ -49,7 +45,7 @@ export function useSelectActionHelper( DatagridContainerProps | DatagridPreviewProps, "itemSelection" | "itemSelectionMethod" | "showSelectAllToggle" | "pageSize" | "itemSelectionMode" >, - selectionHelper?: SelectionHelper + selectionHelper?: SelectionHelperService ): SelectActionHelper { return useMemo( () => diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index e5780ebda8..aa7e4c5664 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,11 +1,11 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { SelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { SelectionHelperService } from "@mendix/widget-plugin-grid/main"; import { createContext, useContext } from "react"; import { EventsController } from "../typings/CellComponent"; import { SelectActionHelper } from "./SelectActionHelper"; export interface LegacyRootScope { - selectionHelper: SelectionHelper | undefined; + selectionHelper: SelectionHelperService | undefined; selectActionHelper: SelectActionHelper; cellEventsController: EventsController; checkboxEventsController: EventsController; 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 0daaf02546..c45412110c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts @@ -1,5 +1,5 @@ import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; -import { DatagridContainerProps } from "../../../typings/DatagridProps"; +import { DatagridContainerProps, PagingPositionEnum } from "../../../typings/DatagridProps"; /** Config for static values that don't change at runtime. */ export interface DatagridConfig { @@ -13,12 +13,14 @@ export interface DatagridConfig { selectorColumnEnabled: boolean; settingsStorageEnabled: boolean; enableSelectAll: boolean; + keepSelection: boolean; + pagingPosition: PagingPositionEnum; } export function datagridConfig(props: DatagridContainerProps): DatagridConfig { const id = `${props.name}:Datagrid@${generateUUID()}`; - return Object.freeze({ + const config: DatagridConfig = { checkboxColumnEnabled: isCheckboxColumnEnabled(props), filtersChannelName: `${id}:events`, id, @@ -28,8 +30,12 @@ export function datagridConfig(props: DatagridContainerProps): DatagridConfig { selectionEnabled: isSelectionEnabled(props), selectorColumnEnabled: props.columnsHidable, settingsStorageEnabled: isSettingsStorageEnabled(props), - enableSelectAll: props.enableSelectAll - }); + enableSelectAll: props.enableSelectAll, + keepSelection: props.keepSelection, + pagingPosition: props.pagingPosition + }; + + return Object.freeze(config); } function isSelectionEnabled(props: DatagridContainerProps): boolean { 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 1878ef2326..d0f032738f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -2,7 +2,7 @@ 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 { emptyStateWidgetsAtom } from "@mendix/widget-plugin-grid/core/models/empty-state.model"; -import { DatasourceService, TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { createSelectionHelper, 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"; @@ -17,6 +17,7 @@ import { DatagridConfig } from "../configs/Datagrid.config"; import { DatasourceParamsController } from "../services/DatasourceParamsController"; import { DerivedLoaderController } from "../services/DerivedLoaderController"; import { PaginationController } from "../services/PaginationController"; +import { SelectionGate } from "../services/SelectionGate.service"; import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../tokens"; injected(ColumnGroupStore, CORE.setupService, CORE.mainGate, CORE.config, DG.filterHost); @@ -30,6 +31,8 @@ injected(GridPersonalizationStore, CORE.setupService, CORE.mainGate, CORE.column injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query); injected(WidgetFilterAPI, DG.parentChannelName, DG.filterHost); injected(emptyStateWidgetsAtom, CORE.mainGate, CORE.atoms.itemCount); +injected(SelectionGate, CORE.mainGate); +injected(createSelectionHelper, CORE.setupService, DG.selectionGate, CORE.config.optional); injected( SelectionCounterViewModel, @@ -69,6 +72,11 @@ export class DatagridContainer extends Container { // Empty placeholder this.bind(DG.emptyPlaceholderVM).toInstance(EmptyPlaceholderViewModel).inSingletonScope(); this.bind(DG.emptyPlaceholderWidgets).toInstance(emptyStateWidgetsAtom).inTransientScope(); + + // Selection gate + this.bind(DG.selectionGate).toInstance(SelectionGate).inTransientScope(); + // Selection helper + this.bind(DG.selectionHelper).toInstance(createSelectionHelper).inSingletonScope(); } /** @@ -143,6 +151,14 @@ export class DatagridContainer extends Container { this.get(DG.personalizationService); } + if (config.selectionEnabled) { + // Create selection helper singleton + this.get(DG.selectionHelper); + } else { + // Override selection helper with undefined to disable selection features + this.bind(DG.selectionHelper).toConstant(undefined); + } + // Hydrate filters from props this.get(DG.combinedFilter).hydrate(props.datasource.filter); } 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 b23babaf98..3f5641510e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -9,3 +9,4 @@ export const [useExportProgressService] = createInjectionHooks(DG.exportProgress export const [useLoaderViewModel] = createInjectionHooks(DG.loaderVM); export const [useMainGate] = createInjectionHooks(CORE.mainGate); export const [usePaginationService] = createInjectionHooks(DG.paginationService); +export const [useSelectionHelper] = createInjectionHooks(DG.selectionHelper); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/services/PaginationController.ts b/packages/pluggableWidgets/datagrid-web/src/model/services/PaginationController.ts index c192303b6c..8f2baff57c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/services/PaginationController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/services/PaginationController.ts @@ -14,6 +14,7 @@ type PaginationKind = `${PaginationEnum}.${ShowPagingButtonsEnum}`; export class PaginationController implements SetupComponent { readonly pagination: PaginationEnum; readonly paginationKind: PaginationKind; + readonly showPagingButtons: ShowPagingButtonsEnum; constructor( host: SetupComponentHost, @@ -23,6 +24,7 @@ export class PaginationController implements SetupComponent { host.add(this); this.pagination = config.pagination; this.paginationKind = `${this.pagination}.${config.showPagingButtons}`; + this.showPagingButtons = config.showPagingButtons; this.setInitParams(); } @@ -42,7 +44,7 @@ export class PaginationController implements SetupComponent { return this.isLimitBased ? limit / pageSize : offset / pageSize; } - get showPagination(): boolean { + get paginationVisible(): boolean { switch (this.paginationKind) { case "buttons.always": return true; @@ -55,6 +57,14 @@ export class PaginationController implements SetupComponent { } } + get hasMoreItems(): boolean { + return this.query.hasMoreItems; + } + + get totalCount(): number | undefined { + return this.query.totalCount; + } + private setInitParams(): void { if (this.pagination === "buttons" || this.config.showNumberOfRows) { this.query.requestTotalCount(true); @@ -65,8 +75,8 @@ export class PaginationController implements SetupComponent { setup(): void {} - setPage = (computePage: (prevPage: number) => number): void => { - const newPage = computePage(this.currentPage); + setPage = (computePage: ((prevPage: number) => number) | number): void => { + const newPage = typeof computePage === "function" ? computePage(this.currentPage) : computePage; if (this.isLimitBased) { this.query.setLimit(newPage * this.pageSize); } else { diff --git a/packages/pluggableWidgets/datagrid-web/src/model/services/SelectionGate.service.ts b/packages/pluggableWidgets/datagrid-web/src/model/services/SelectionGate.service.ts new file mode 100644 index 0000000000..4e0674d73b --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/services/SelectionGate.service.ts @@ -0,0 +1,20 @@ +import { SelectionDynamicProps } from "@mendix/widget-plugin-grid/main"; +import { DerivedPropsGate, MappedGate } from "@mendix/widget-plugin-mobx-kit/main"; +import { MainGateProps } from "../../../typings/MainGateProps"; + +export class SelectionGate extends MappedGate { + constructor(gate: DerivedPropsGate) { + super(gate, map); + } +} + +function map(props: MainGateProps): SelectionDynamicProps { + if (!props.itemSelection) { + throw new Error("'itemSelection' prop is required for SelectionGate"); + } + return { + selection: props.itemSelection, + datasource: props.datasource, + onSelectionChange: props.onSelectionChange + }; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index 8b4ecf3e3a..ce96da5d6a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -1,7 +1,13 @@ 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 { QueryService, SelectAllService, TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { + QueryService, + SelectAllService, + SelectionDynamicProps, + SelectionHelperService, + TaskProgressService +} from "@mendix/widget-plugin-grid/main"; import { SelectAllFeature } from "@mendix/widget-plugin-grid/select-all/select-all.feature"; import { BarStore, @@ -43,7 +49,7 @@ export const CORE_TOKENS = { config: token("DatagridConfig"), - mainGate: token>("MainGate"), + mainGate: token>("@gate:MainGate"), selection: { selectedCount: token>("@computed:selectedCount"), @@ -86,10 +92,13 @@ export const DG_TOKENS = { personalizationService: token("GridPersonalizationStore"), query: token("QueryService"), - queryGate: token>("GateForQueryService"), + queryGate: token>("@gate:GateForQueryService"), selectionCounterCfg: token<{ position: "top" | "bottom" | "off" }>("SelectionCounterConfig"), - selectionCounterVM: token("SelectionCounterViewModel") + selectionCounterVM: token("SelectionCounterViewModel"), + + selectionGate: token>("@gate:GateForSelectionHelper"), + selectionHelper: token("SelectionHelperService") }; /** "Select all" module tokens. */ diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index 9fe9f153d5..dd421de1c2 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -84,21 +84,14 @@ export function mockWidgetProps(): WidgetProps { data: [{ id: "123456" as GUID }], exporting: false, filterRenderer: () => , - hasMoreItems: false, headerWrapperRenderer: (_index, header) => header, id, onExportCancel: jest.fn(), - page: 1, - pageSize: 10, paginationType: "buttons", - paging: false, - pagingPosition: "bottom", - showPagingButtons: "auto", visibleColumns: columns, availableColumns: columns, columnsSwap: jest.fn(), setIsResizing: jest.fn(), - setPage: jest.fn(), processedRows: 0, selectActionHelper: mockSelectionProps(), cellEventsController: { getProps: () => Object.create({}) }, diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts index de4af19873..1f666afc4a 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts @@ -14,6 +14,7 @@ export type MainGateProps = Pick< | "enableSelectAll" | "itemSelection" | "name" + | "onSelectionChange" | "pageSize" | "pagination" | "refreshIndicator" diff --git a/packages/shared/widget-plugin-grid/src/interfaces/MultiSelectionService.ts b/packages/shared/widget-plugin-grid/src/interfaces/MultiSelectionService.ts new file mode 100644 index 0000000000..bc6a321fab --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/interfaces/MultiSelectionService.ts @@ -0,0 +1,23 @@ +import { ObjectItem } from "mendix"; +import { MoveEvent1D, MoveEvent2D, MultiSelectionStatus, SelectionMode } from "../selection/types"; + +export interface MultiSelectionService { + type: "Multi"; + selectionStatus: MultiSelectionStatus; + togglePageSelection(): void; + isSelected(item: ObjectItem): boolean; + add(item: ObjectItem): void; + remove(item: ObjectItem): void; + reduceTo(item: ObjectItem): void; + clearSelection(): void; + selectAll(): void; + selectNone(): void; + selectUpTo(item: ObjectItem, mode: SelectionMode): void; + selectUpToAdjacent( + item: ObjectItem, + shiftKey: boolean, + mode: SelectionMode, + event: MoveEvent1D | MoveEvent2D + ): void; + togglePageSelection(): void; +} diff --git a/packages/shared/widget-plugin-grid/src/interfaces/SelectionDynamicProps.ts b/packages/shared/widget-plugin-grid/src/interfaces/SelectionDynamicProps.ts new file mode 100644 index 0000000000..b94fc7a40a --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/interfaces/SelectionDynamicProps.ts @@ -0,0 +1,7 @@ +import { ActionValue, ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; + +export interface SelectionDynamicProps { + selection: SelectionSingleValue | SelectionMultiValue; + datasource: ListValue; + onSelectionChange: ActionValue | undefined; +} diff --git a/packages/shared/widget-plugin-grid/src/interfaces/SelectionHelperService.ts b/packages/shared/widget-plugin-grid/src/interfaces/SelectionHelperService.ts new file mode 100644 index 0000000000..45977b2368 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/interfaces/SelectionHelperService.ts @@ -0,0 +1,4 @@ +import { MultiSelectionService } from "./MultiSelectionService"; +import { SingleSelectionService } from "./SingleSelectionService"; + +export type SelectionHelperService = MultiSelectionService | SingleSelectionService; diff --git a/packages/shared/widget-plugin-grid/src/interfaces/SingleSelectionService.ts b/packages/shared/widget-plugin-grid/src/interfaces/SingleSelectionService.ts new file mode 100644 index 0000000000..24cffaadda --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/interfaces/SingleSelectionService.ts @@ -0,0 +1,8 @@ +import { ObjectItem } from "mendix"; + +export interface SingleSelectionService { + type: "Single"; + isSelected(item: ObjectItem): boolean; + reduceTo(item: ObjectItem): void; + remove(): void; +} diff --git a/packages/shared/widget-plugin-grid/src/main.ts b/packages/shared/widget-plugin-grid/src/main.ts index 2b90b358d6..aeda935552 100644 --- a/packages/shared/widget-plugin-grid/src/main.ts +++ b/packages/shared/widget-plugin-grid/src/main.ts @@ -1,6 +1,10 @@ export { DatasourceService } from "./core/Datasource.service"; export { ProgressService } from "./core/Progress.service"; export type { QueryService } from "./interfaces/QueryService"; +export type { SelectionDynamicProps } from "./interfaces/SelectionDynamicProps"; +export { type SelectionHelperService } from "./interfaces/SelectionHelperService"; export type { TaskProgressService } from "./interfaces/TaskProgressService"; export { SelectAllService } from "./select-all/SelectAll.service"; export { SelectionCounterViewModel } from "./selection-counter/SelectionCounter.viewModel"; +export * from "./selection/context"; +export { createSelectionHelper } from "./selection/createSelectionHelper"; diff --git a/packages/shared/widget-plugin-grid/src/selection/context.ts b/packages/shared/widget-plugin-grid/src/selection/context.ts index 851bbb8c25..ac17f5a547 100644 --- a/packages/shared/widget-plugin-grid/src/selection/context.ts +++ b/packages/shared/widget-plugin-grid/src/selection/context.ts @@ -1,17 +1,11 @@ import { Context, createContext, useContext, useMemo } from "react"; -import { SelectionHelper } from "./helpers.js"; +import { MultiSelectionService } from "../interfaces/MultiSelectionService.js"; +import { SelectionHelperService } from "../interfaces/SelectionHelperService.js"; import { error, Result, value } from "./result-meta.js"; -import { MultiSelectionStatus } from "./types.js"; const CONTEXT_OBJECT_PATH = "com.mendix.widgets.web.selectable.selectionContext" as const; -interface SelectionStore { - /** @observable */ - selectionStatus: MultiSelectionStatus; - togglePageSelection(): void; -} - -type SelectionContextObject = Context; +type SelectionContextObject = Context; declare global { interface Window { @@ -20,24 +14,16 @@ declare global { } export function getGlobalSelectionContext(): SelectionContextObject { - return (window[CONTEXT_OBJECT_PATH] ??= createContext(undefined)); + return (window[CONTEXT_OBJECT_PATH] ??= createContext(undefined)); } -type UseCreateSelectionContextValueReturn = SelectionStore | undefined; - export function useCreateSelectionContextValue( - selection: SelectionHelper | undefined -): UseCreateSelectionContextValueReturn { - return useMemo(() => { - if (selection?.type === "Multi") { - return selection; - } - - return undefined; - }, [selection]); + selection: SelectionHelperService | undefined +): MultiSelectionService | undefined { + return useMemo(() => (selection?.type === "Multi" ? selection : undefined), [selection]); } -export function useSelectionContextValue(): Result { +export function useSelectionContextValue(): Result { const context = getGlobalSelectionContext(); const contextValue = useContext(context); diff --git a/packages/shared/widget-plugin-grid/src/selection/createSelectionHelper.ts b/packages/shared/widget-plugin-grid/src/selection/createSelectionHelper.ts new file mode 100644 index 0000000000..ab5cf21051 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/createSelectionHelper.ts @@ -0,0 +1,61 @@ +import { DerivedPropsGate, disposeBatch, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; +import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { autorun, comparer, reaction } from "mobx"; +import { SelectionHelperService } from "../interfaces/SelectionHelperService"; +import { SelectionDynamicProps } from "../main"; +import { MultiSelectionHelper, SingleSelectionHelper } from "./helpers"; + +export function createSelectionHelper( + host: SetupComponentHost, + gate: DerivedPropsGate, + config: { keepSelection: boolean } = { keepSelection: false } +): SelectionHelperService { + const { selection, datasource } = gate.props; + + let helper: SelectionHelperService; + if (selection.type === "Multi") { + helper = new MultiSelectionHelper(selection, datasource.items ?? []); + } else { + helper = new SingleSelectionHelper(selection); + } + if (config.keepSelection) { + selection.setKeepSelection(() => true); + } + + function setup(): () => void { + const [add, disposeAll] = disposeBatch(); + + add( + autorun(() => { + const { selection, datasource } = gate.props; + if (helper instanceof MultiSelectionHelper) { + helper.updateProps(selection as SelectionMultiValue, datasource.items ?? []); + } + if (helper instanceof SingleSelectionHelper) { + helper.updateProps(selection as SelectionSingleValue); + } + }) + ); + + if (gate.props.onSelectionChange) { + const cleanup = reaction( + (): ObjectItem[] => { + const selected = gate.props.selection.selection; + if (Array.isArray(selected)) return selected; + if (selected !== undefined) return [selected]; + return []; + }, + () => executeAction(gate.props.onSelectionChange), + { equals: comparer.structural } + ); + add(cleanup); + } + + return disposeAll; + } + + host.add({ setup }); + + return helper; +} diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index 05738b5416..507e1f7bda 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -2,9 +2,11 @@ import { executeAction } from "@mendix/widget-plugin-platform/framework/execute- import type { ActionValue, ListValue, ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { action, computed, makeObservable, observable } from "mobx"; import { useEffect, useRef, useState } from "react"; +import { MultiSelectionService } from "../interfaces/MultiSelectionService"; +import { SingleSelectionService } from "../interfaces/SingleSelectionService"; import { Direction, MoveEvent1D, MoveEvent2D, MultiSelectionStatus, ScrollKeyCode, SelectionMode, Size } from "./types"; -class SingleSelectionHelper { +export class SingleSelectionHelper implements SingleSelectionService { type = "Single" as const; constructor(private selectionValue: SelectionSingleValue) {} @@ -18,12 +20,12 @@ class SingleSelectionHelper { reduceTo(value: ObjectItem): void { this.selectionValue.setSelection(value); } - remove(_value: ObjectItem): void { + remove(): void { this.selectionValue.setSelection(undefined); } } -export class MultiSelectionHelper { +export class MultiSelectionHelper implements MultiSelectionService { type = "Multi" as const; private rangeStart: number | undefined; private rangeEnd: number | undefined; @@ -339,6 +341,7 @@ export class MultiSelectionHelper { const clamp = (num: number, min: number, max: number): number => Math.min(Math.max(num, min), max); +/** @deprecated use container and createSelectionHelper instead. */ export function useSelectionHelper( selection: SelectionSingleValue | SelectionMultiValue | undefined, dataSource: ListValue, @@ -400,7 +403,6 @@ function selectionStateHandler( return keepSelection === "always keep" ? () => true : () => false; } -export type { SingleSelectionHelper }; export type SelectionHelper = SingleSelectionHelper | MultiSelectionHelper; function objectListEqual(a: ObjectItem[], b: ObjectItem[]): boolean { diff --git a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts index 5e35c57966..08c4c48bad 100644 --- a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts +++ b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts @@ -1,11 +1,11 @@ import { ObjectItem } from "mendix"; -import { SelectionHelper } from "./helpers"; +import { SelectionHelperService } from "../interfaces/SelectionHelperService"; import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType, WidgetSelectionProperty } from "./types"; export class SelectActionHandler { constructor( private selection: WidgetSelectionProperty, - protected selectionHelper: SelectionHelper | undefined + protected selectionHelper: SelectionHelperService | undefined ) {} get selectionType(): SelectionType { diff --git a/packages/shared/widget-plugin-mobx-kit/src/MappedGate.ts b/packages/shared/widget-plugin-mobx-kit/src/MappedGate.ts new file mode 100644 index 0000000000..8c6061291b --- /dev/null +++ b/packages/shared/widget-plugin-mobx-kit/src/MappedGate.ts @@ -0,0 +1,16 @@ +import { computed, makeObservable } from "mobx"; +import { DerivedPropsGate } from "./interfaces/DerivedPropsGate"; + +/** Helper class to create gate that map props from another gate. */ +export class MappedGate implements DerivedPropsGate { + constructor( + private gate: DerivedPropsGate, + private map: (props: T1) => T2 + ) { + makeObservable(this, { props: computed }); + } + + get props(): T2 { + return this.map(this.gate.props); + } +} diff --git a/packages/shared/widget-plugin-mobx-kit/src/main.ts b/packages/shared/widget-plugin-mobx-kit/src/main.ts index 75711e90fd..8bc783868c 100644 --- a/packages/shared/widget-plugin-mobx-kit/src/main.ts +++ b/packages/shared/widget-plugin-mobx-kit/src/main.ts @@ -10,4 +10,5 @@ export { autoEffect } from "./lib/autoEffect"; export { createEmitter } from "./lib/createEmitter"; export type { Emitter } from "./lib/createEmitter"; export { disposeBatch } from "./lib/disposeBatch"; +export { MappedGate } from "./MappedGate"; export { SetupHost } from "./SetupHost"; From 277310098d8927c67c6fdbecf2b8d152e5c19545 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:48:20 +0100 Subject: [PATCH 09/21] refactor: rewrite grid style to atom --- .../datagrid-web/src/components/Grid.tsx | 28 ++++++------- .../datagrid-web/src/components/Widget.tsx | 37 +---------------- .../src/model/configs/Datagrid.config.ts | 9 ++++- .../model/containers/Datagrid.container.ts | 4 ++ .../src/model/hooks/injection-hooks.ts | 1 + .../src/model/models/grid.model.ts | 40 +++++++++++++++++++ .../datagrid-web/src/model/tokens.ts | 6 ++- 7 files changed, 72 insertions(+), 53 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx index 96948a3bca..015fb3f539 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Grid.tsx @@ -1,18 +1,18 @@ -import classNames from "classnames"; -import { ComponentPropsWithoutRef, ReactElement } from "react"; - -type P = Omit, "role">; - -export interface GridProps extends P { - className?: string; -} - -export function Grid(props: GridProps): ReactElement { - const { className, style, children, ...rest } = props; +import { observer } from "mobx-react-lite"; +import { PropsWithChildren, ReactElement } from "react"; +import { useDatagridConfig, useGridStyle } from "../model/hooks/injection-hooks"; +export const Grid = observer(function Grid(props: PropsWithChildren): ReactElement { + const config = useDatagridConfig(); + const style = useGridStyle().get(); return ( -
- {children} +
+ {props.children}
); -} +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 7193c56166..ea9c0dcfaa 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -111,22 +111,12 @@ const Main = observer((props: WidgetProps): ReactElemen const basicData = useBasicData(); - const cssGridStyles = gridStyle(visibleColumns, { - selectItemColumn: selectActionHelper.showCheckboxColumn, - visibilitySelectorColumn: columnsHidable - }); - - const selectionEnabled = selectActionHelper.selectionType !== "None"; - return ( - + (props: WidgetProps): ReactElemen ); }); - -function gridStyle(columns: GridColumn[], optional: OptionalColumns): CSSProperties { - const columnSizes = columns.map(c => c.getCssWidth()); - - const sizes: string[] = []; - - if (optional.selectItemColumn) { - sizes.push("48px"); - } - - sizes.push(...columnSizes); - - if (optional.visibilitySelectorColumn) { - sizes.push("54px"); - } - - return { - gridTemplateColumns: sizes.join(" ") - }; -} - -type OptionalColumns = { - selectItemColumn?: boolean; - visibilitySelectorColumn?: boolean; -}; 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 c45412110c..858d35fe01 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts @@ -15,6 +15,7 @@ export interface DatagridConfig { enableSelectAll: boolean; keepSelection: boolean; pagingPosition: PagingPositionEnum; + multiselectable: true | undefined; } export function datagridConfig(props: DatagridContainerProps): DatagridConfig { @@ -32,12 +33,18 @@ export function datagridConfig(props: DatagridContainerProps): DatagridConfig { settingsStorageEnabled: isSettingsStorageEnabled(props), enableSelectAll: props.enableSelectAll, keepSelection: props.keepSelection, - pagingPosition: props.pagingPosition + pagingPosition: props.pagingPosition, + multiselectable: isMultiselectable(props) }; return Object.freeze(config); } +function isMultiselectable(props: DatagridContainerProps): true | undefined { + const type = props.itemSelection?.type; + return type === "Multi" ? true : undefined; +} + function isSelectionEnabled(props: DatagridContainerProps): boolean { return props.itemSelection !== undefined; } 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 d0f032738f..ca8d9ba411 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts @@ -14,6 +14,7 @@ import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore"; import { DatagridConfig } from "../configs/Datagrid.config"; +import { gridStyleAtom } from "../models/grid.model"; import { DatasourceParamsController } from "../services/DatasourceParamsController"; import { DerivedLoaderController } from "../services/DerivedLoaderController"; import { PaginationController } from "../services/PaginationController"; @@ -33,6 +34,7 @@ injected(WidgetFilterAPI, DG.parentChannelName, DG.filterHost); injected(emptyStateWidgetsAtom, CORE.mainGate, CORE.atoms.itemCount); injected(SelectionGate, CORE.mainGate); injected(createSelectionHelper, CORE.setupService, DG.selectionGate, CORE.config.optional); +injected(gridStyleAtom, CORE.columnsStore, CORE.config); injected( SelectionCounterViewModel, @@ -72,6 +74,8 @@ export class DatagridContainer extends Container { // Empty placeholder this.bind(DG.emptyPlaceholderVM).toInstance(EmptyPlaceholderViewModel).inSingletonScope(); this.bind(DG.emptyPlaceholderWidgets).toInstance(emptyStateWidgetsAtom).inTransientScope(); + // Grid columns style + this.bind(DG.gridColumnsStyle).toInstance(gridStyleAtom).inTransientScope(); // Selection gate this.bind(DG.selectionGate).toInstance(SelectionGate).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 3f5641510e..f157e37850 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts @@ -10,3 +10,4 @@ export const [useLoaderViewModel] = createInjectionHooks(DG.loaderVM); export const [useMainGate] = createInjectionHooks(CORE.mainGate); export const [usePaginationService] = createInjectionHooks(DG.paginationService); export const [useSelectionHelper] = createInjectionHooks(DG.selectionHelper); +export const [useGridStyle] = createInjectionHooks(DG.gridColumnsStyle); diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts new file mode 100644 index 0000000000..559b2bf366 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/model/models/grid.model.ts @@ -0,0 +1,40 @@ +import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; +import { computed } from "mobx"; +import { CSSProperties } from "react"; +import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; +import { DatagridConfig } from "../configs/Datagrid.config"; + +export function gridStyleAtom(columns: ColumnGroupStore, config: DatagridConfig): ComputedAtom { + return computed(() => { + return gridStyle(columns.visibleColumns, { + checkboxColumn: config.checkboxColumnEnabled, + selectorColumn: config.selectorColumnEnabled + }); + }); +} + +function gridStyle( + columns: Array<{ getCssWidth(): string }>, + optional: { + checkboxColumn?: boolean; + selectorColumn?: boolean; + } +): CSSProperties { + const columnSizes = columns.map(c => c.getCssWidth()); + + const sizes: string[] = []; + + if (optional.checkboxColumn) { + sizes.push("48px"); + } + + sizes.push(...columnSizes); + + if (optional.selectorColumn) { + sizes.push("54px"); + } + + return { + gridTemplateColumns: sizes.join(" ") + }; +} diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts index ce96da5d6a..6e28788afe 100644 --- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts +++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts @@ -18,7 +18,7 @@ import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection- import { ComputedAtom, DerivedPropsGate, Emitter } from "@mendix/widget-plugin-mobx-kit/main"; import { token } from "brandi"; import { ListValue } from "mendix"; -import { ReactNode } from "react"; +import { CSSProperties, ReactNode } from "react"; import { MainGateProps } from "../../typings/MainGateProps"; import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel"; import { SelectAllBarViewModel } from "../features/select-all/SelectAllBar.viewModel"; @@ -98,7 +98,9 @@ export const DG_TOKENS = { selectionCounterVM: token("SelectionCounterViewModel"), selectionGate: token>("@gate:GateForSelectionHelper"), - selectionHelper: token("SelectionHelperService") + selectionHelper: token("SelectionHelperService"), + + gridColumnsStyle: token>("@computed:GridColumnsStyle") }; /** "Select all" module tokens. */ From f44bf6f18faa4f74d3e827aa2d39303e8f5f2729 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:45:53 +0100 Subject: [PATCH 10/21] refactor: rewrite grid body --- .../datagrid-web/src/components/GridBody.tsx | 145 ++++++++++-------- .../datagrid-web/src/components/Widget.tsx | 9 +- .../src/model/configs/Datagrid.config.ts | 16 +- .../src/model/hooks/injection-hooks.ts | 3 + 4 files changed, 100 insertions(+), 73 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx index d30322478c..807d830907 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx @@ -1,88 +1,107 @@ import { useInfiniteControl } from "@mendix/widget-plugin-grid/components/InfiniteBody"; import classNames from "classnames"; -import { Fragment, ReactElement, ReactNode, useCallback } from "react"; -import { LoadingTypeEnum } from "../../typings/DatagridProps"; -import { usePaginationService } from "../model/hooks/injection-hooks"; +import { observer } from "mobx-react-lite"; +import { Fragment, PropsWithChildren, ReactElement, ReactNode, RefObject, UIEventHandler, useCallback } from "react"; +import { + useDatagridConfig, + useItemCount, + useLoaderViewModel, + usePaginationService, + useVisibleColumnsCount +} from "../model/hooks/injection-hooks"; import { RowSkeletonLoader } from "./loader/RowSkeletonLoader"; import { SpinnerLoader } from "./loader/SpinnerLoader"; -interface Props { - className?: string; - children?: ReactNode; - loadingType: LoadingTypeEnum; - isFirstLoad: boolean; - isFetchingNextBatch?: boolean; - columnsHidable: boolean; - columnsSize: number; - rowsSize: number; -} - -export function GridBody(props: Props): ReactElement { +export function GridBody(props: PropsWithChildren): ReactElement { const { children } = props; - - const paging = usePaginationService(); - const pageSize = paging.pageSize; - const setPage = useCallback((cb: (n: number) => number) => paging.setPage(cb), [paging]); - - const isInfinite = paging.pagination === "virtualScrolling"; - const [trackScrolling, bodySize, containerRef] = useInfiniteControl({ - hasMoreItems: paging.hasMoreItems, - isInfinite, - setPage - }); - - const content = (): ReactElement => { - if (props.isFirstLoad) { - return 0 ? props.rowsSize : pageSize} />; - } - return ( - - {children} - {props.isFetchingNextBatch && } - - ); - }; + const { bodySize, containerRef, isInfinite, handleScroll } = useBodyScroll(); return (
0 ? { maxHeight: `${bodySize}px` } : {}} role="rowgroup" ref={containerRef} - onScroll={isInfinite ? trackScrolling : undefined} + onScroll={handleScroll} > - {content()} + {children}
); } -interface LoaderProps { - loadingType: LoadingTypeEnum; - columnsHidable: boolean; - columnsSize: number; - rowsSize: number; - useBorderTop?: boolean; -} +const ContentGuard = observer(function ContentGuard(props: PropsWithChildren): ReactNode { + const loaderVM = useLoaderViewModel(); + const { pageSize } = usePaginationService(); + const config = useDatagridConfig(); + const columnsCount = useVisibleColumnsCount().get(); + const itemCount = useItemCount().get(); -function Loader(props: LoaderProps): ReactElement { - if (props.loadingType === "spinner") { + if (loaderVM.isFirstLoad && config.loadingType === "spinner") { + return ; + } + + if (loaderVM.isFirstLoad) { return ( -
- -
+ 0 ? itemCount : pageSize} + useBorderTop + /> ); } return ( - + + {props.children} + {(() => { + if (loaderVM.isFetchingNextBatch && config.loadingType === "spinner") { + return ; + } + + if (loaderVM.isFetchingNextBatch) { + return ( + + ); + } + + return null; + })()} + ); +}); + +function useBodyScroll(): { + handleScroll: UIEventHandler | undefined; + bodySize: number; + containerRef: RefObject; + isInfinite: boolean; +} { + const paging = usePaginationService(); + const setPage = useCallback((cb: (n: number) => number) => paging.setPage(cb), [paging]); + + const isInfinite = paging.pagination === "virtualScrolling"; + const [trackScrolling, bodySize, containerRef] = useInfiniteControl({ + hasMoreItems: paging.hasMoreItems, + isInfinite, + setPage + }); + + return { + handleScroll: isInfinite ? trackScrolling : undefined, + bodySize, + containerRef, + isInfinite + }; } + +const Spinner = (): ReactNode => ( +
+ +
+); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index ea9c0dcfaa..a3bca322da 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -135,14 +135,7 @@ const Main = observer((props: WidgetProps): ReactElemen /> {showRefreshIndicator ? : null} - + Date: Tue, 18 Nov 2025 11:12:38 +0100 Subject: [PATCH 11/21] test: add new tests --- .../src/components/__tests__/Table.spec.tsx | 690 ------ .../__snapshots__/Table.spec.tsx.snap | 1926 ----------------- .../model/configs/__tests__/config.spec.ts | 84 + .../__tests__/createDatagridContainer.spec.ts | 58 + .../containers/createDatagridContainer.ts | 34 + .../src/model/hooks/useDatagridContainer.ts | 32 +- .../datagrid-web/src/utils/test-utils.tsx | 45 +- 7 files changed, 219 insertions(+), 2650 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx delete mode 100644 packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/configs/__tests__/config.spec.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/containers/__tests__/createDatagridContainer.spec.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/model/containers/createDatagridContainer.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx deleted file mode 100644 index c0d379ab1e..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ /dev/null @@ -1,690 +0,0 @@ -import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; -import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/main"; -import { MultiSelectionStatus, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { list, listWidget, objectItems, SelectionMultiValueBuilder } from "@mendix/widget-plugin-test-utils"; -import "@testing-library/jest-dom"; -import { cleanup, getAllByRole, getByRole, queryByRole, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ListValue, ObjectItem, SelectionMultiValue } from "mendix"; -import { ReactElement } from "react"; -import { ItemSelectionMethodEnum } from "typings/DatagridProps"; -import { CellEventsController, useCellEventsController } from "../../features/row-interaction/CellEventsController"; -import { - CheckboxEventsController, - useCheckboxEventsController -} from "../../features/row-interaction/CheckboxEventsController"; -import { SelectActionHelper, useSelectActionHelper } from "../../helpers/SelectActionHelper"; -import { LegacyContext, LegacyRootScope } from "../../helpers/root-context"; -import { GridBasicData } from "../../helpers/state/GridBasicData"; -import { GridColumn } from "../../typings/GridColumn"; -import { column, mockGridColumn, mockWidgetProps } from "../../utils/test-utils"; -import { Widget, WidgetProps } from "../Widget"; - -// you can also pass the mock implementation -// to jest.fn as an argument -window.IntersectionObserver = jest.fn(() => ({ - root: null, - rootMargin: "", - thresholds: [0, 1], - disconnect: jest.fn(), - observe: jest.fn(), - unobserve: jest.fn(), - takeRecords: jest.fn() -})); - -function withCtx( - widgetProps: WidgetProps, - contextOverrides: Partial = {} -): ReactElement { - const defaultBasicData = { - gridInteractive: false, - selectionStatus: "none" as const, - setSelectionHelper: jest.fn(), - exportDialogLabel: undefined, - cancelExportLabel: undefined, - selectRowLabel: undefined, - selectAllRowsLabel: undefined - }; - - const defaultSelectionCountStore = { - selectedCount: 0, - displayCount: "", - fmtSingular: "%d row selected", - fmtPlural: "%d rows selected" - }; - - const mockContext = { - basicData: defaultBasicData as unknown as GridBasicData, - selectionHelper: undefined, - selectActionHelper: widgetProps.selectActionHelper, - cellEventsController: widgetProps.cellEventsController, - checkboxEventsController: widgetProps.checkboxEventsController, - focusController: widgetProps.focusController, - selectionCountStore: defaultSelectionCountStore as unknown as SelectionCounterViewModel, - ...contextOverrides - }; - - return ( - - - - ); -} - -// Helper function to render Widget with root context -function renderWithRootContext( - widgetProps: WidgetProps, - contextOverrides: Partial = {} -): ReturnType { - return render(withCtx(widgetProps, contextOverrides)); -} - -// TODO: Rewrite or delete these tests -// eslint-disable-next-line jest/no-disabled-tests -describe.skip("Table", () => { - it("renders the structure correctly", () => { - const component = renderWithRootContext(mockWidgetProps()); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with sorting", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsSortable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with resizing", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsResizable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with dragging", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsDraggable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with filtering", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsFilterable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with hiding", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), columnsHidable: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with paging", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), paging: true }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with custom filtering", () => { - const props = mockWidgetProps(); - const columns = [column("Test")].map((col, index) => mockGridColumn(col, index)); - props.columnsFilterable = true; - props.visibleColumns = columns; - props.availableColumns = columns; - const component = renderWithRootContext(props); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with empty placeholder", () => { - const component = renderWithRootContext({ - ...mockWidgetProps() - }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with column alignments", () => { - const props = mockWidgetProps(); - const columns = [ - column("Test", col => { - col.alignment = "center"; - }), - column("Test 2", col => (col.alignment = "right")) - ].map((col, index) => mockGridColumn(col, index)); - - props.visibleColumns = columns; - props.availableColumns = columns; - - const component = renderWithRootContext(props); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with dynamic row class", () => { - const component = renderWithRootContext({ ...mockWidgetProps(), rowClass: () => "myclass" }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly for preview when no header is provided", () => { - const props = mockWidgetProps(); - const columns = [column("", col => (col.alignment = "center"))].map((col, index) => mockGridColumn(col, index)); - props.preview = true; - props.visibleColumns = columns; - props.availableColumns = columns; - - const component = renderWithRootContext(props); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with header wrapper", () => { - const component = renderWithRootContext({ - ...mockWidgetProps(), - headerWrapperRenderer: (index, header) => ( -
- {header} -
- ) - }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - it("renders the structure correctly with header filters and a11y", () => { - const component = renderWithRootContext({ - ...mockWidgetProps(), - headerContent: ( -
- -
- ), - headerTitle: "filter title" - }); - - expect(component.asFragment()).toMatchSnapshot(); - }); - - describe("with selection method checkbox", () => { - let props: ReturnType; - - beforeEach(() => { - props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Single", undefined, "checkbox", false, 5, "clear"); - props.paging = true; - props.data = objectItems(3); - }); - - it("render method class", () => { - const { container } = renderWithRootContext(props, {}); - - expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-checkbox"); - }); - - it("render an extra column and add class to each selected row", () => { - props.selectActionHelper.isSelected = () => true; - - const { asFragment } = renderWithRootContext(props, {}); - - expect(asFragment()).toMatchSnapshot(); - }); - - it("render correct number of checked checkboxes", () => { - const [a, b, c, d, e, f] = (props.data = objectItems(6)); - let selection: ObjectItem[] = []; - props.selectActionHelper.isSelected = item => selection.includes(item); - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const getChecked = () => screen.getAllByRole("checkbox").filter(elt => elt.checked); - - const { rerender } = render(withCtx(props)); - - expect(getChecked()).toHaveLength(0); - - selection = [a, b, c]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(3); - - selection = [c]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(1); - - selection = [d, e]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(2); - - selection = [f, e, d, a]; - rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); - expect(getChecked()).toHaveLength(4); - }); - - it("call onSelect when checkbox is clicked", async () => { - const items = props.data; - const onSelect = jest.fn(); - props.selectActionHelper.onSelect = onSelect; - props.checkboxEventsController = new CheckboxEventsController( - item => ({ - item, - selectionMethod: props.selectActionHelper.selectionMethod, - selectionType: "Single", - selectionMode: "clear", - pageSize: props.pageSize - }), - onSelect, - jest.fn(), - jest.fn(), - jest.fn() - ); - - // renderWithRootContext(props, { - // basicData: { gridInteractive: true } as unknown as GridBasicData - // }); - - const checkbox1 = screen.getAllByRole("checkbox")[0]; - const checkbox3 = screen.getAllByRole("checkbox")[2]; - - await userEvent.click(checkbox1); - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, true); - - await userEvent.click(checkbox1); - expect(onSelect).toHaveBeenCalledTimes(2); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, true); - - await userEvent.click(checkbox3); - expect(onSelect).toHaveBeenCalledTimes(3); - expect(onSelect).toHaveBeenLastCalledWith(items[2], false, true); - - await userEvent.click(checkbox3); - expect(onSelect).toHaveBeenCalledTimes(4); - expect(onSelect).toHaveBeenLastCalledWith(items[2], false, true); - }); - }); - - it("not render header checkbox when showCheckboxColumn is false", () => { - const props = mockWidgetProps(); - props.data = objectItems(5); - props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", false, 5, "clear"); - renderWithRootContext(props); - - const colheader = screen.getAllByRole("columnheader")[0]; - expect(queryByRole(colheader, "checkbox")).toBeNull(); - }); - - describe("with multi selection helper", () => { - it("render header checkbox if helper is given and checkbox state depends on the helper status", () => { - const props = mockWidgetProps(); - props.data = objectItems(5); - props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); - - const renderWithStatus = (_status: MultiSelectionStatus): ReturnType => { - return renderWithRootContext(props); - }; - - renderWithStatus("none"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).not.toBeChecked(); - - cleanup(); - renderWithStatus("some"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); - - cleanup(); - renderWithStatus("all"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); - }); - - it("not render header checkbox if method is rowClick", () => { - const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "rowClick", false, 5, "clear"); - - renderWithRootContext(props); - - const colheader = screen.getAllByRole("columnheader")[0]; - expect(queryByRole(colheader, "checkbox")).toBeNull(); - }); - - it("call onSelectAll when header checkbox is clicked", async () => { - const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); - props.selectActionHelper.onSelectAll = jest.fn(); - - renderWithRootContext(props, {}); - - const checkbox = screen.getAllByRole("checkbox")[0]; - - await userEvent.click(checkbox); - expect(props.selectActionHelper.onSelectAll).toHaveBeenCalledTimes(1); - - await userEvent.click(checkbox); - expect(props.selectActionHelper.onSelectAll).toHaveBeenCalledTimes(2); - }); - }); - - describe("with selection method rowClick", () => { - let props: ReturnType; - - beforeEach(() => { - props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Single", undefined, "rowClick", true, 5, "clear"); - props.paging = true; - props.data = objectItems(3); - }); - - it("render method class", () => { - const { container } = renderWithRootContext(props, {}); - - expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-click"); - }); - - it("add class to each selected cell", () => { - props.selectActionHelper.isSelected = () => true; - - const { asFragment } = renderWithRootContext(props, {}); - - expect(asFragment()).toMatchSnapshot(); - }); - - it("call onSelect when cell is clicked", async () => { - const items = props.data; - const onSelect = jest.fn(); - const columns = [column("Column A"), column("Column B")].map((col, index) => mockGridColumn(col, index)); - props.visibleColumns = columns; - props.availableColumns = columns; - props.cellEventsController = new CellEventsController( - item => ({ - item, - selectionType: props.selectActionHelper.selectionType, - selectionMethod: props.selectActionHelper.selectionMethod, - selectionMode: "clear", - clickTrigger: "none", - pageSize: props.pageSize - }), - onSelect, - jest.fn(), - jest.fn(), - jest.fn(), - jest.fn() - ); - - renderWithRootContext(props, {}); - - const rows = screen.getAllByRole("row").slice(1); - expect(rows).toHaveLength(3); - - const [row1, row2] = rows; - const [cell1, cell2] = getAllByRole(row1, "gridcell"); - const [cell3, cell4] = getAllByRole(row2, "gridcell"); - - const sleep = (t: number): Promise => new Promise(res => setTimeout(res, t)); - - // Click cell1 two times - await userEvent.click(cell1); - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, false); - await sleep(320); - - await userEvent.click(cell1); - expect(onSelect).toHaveBeenCalledTimes(2); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, false); - await sleep(320); - - // Click cell2 - await userEvent.click(cell2); - expect(onSelect).toHaveBeenCalledTimes(3); - expect(onSelect).toHaveBeenLastCalledWith(items[0], false, false); - await sleep(320); - - // Click cell3 and cell4 - await userEvent.click(cell4); - expect(onSelect).toHaveBeenCalledTimes(4); - expect(onSelect).toHaveBeenLastCalledWith(items[1], false, false); - await sleep(320); - - await userEvent.click(cell3); - expect(onSelect).toHaveBeenCalledTimes(5); - expect(onSelect).toHaveBeenLastCalledWith(items[1], false, false); - }); - }); - - describe("when selecting is enabled, allow the user to select multiple rows", () => { - let items: ReturnType; - let props: ReturnType; - let selection: SelectionMultiValue; - let ds: ListValue; - - function WidgetWithSelectionHelper({ - selectionMethod, - ...props - }: WidgetProps & { - selectionMethod: ItemSelectionMethodEnum; - }): ReactElement { - const helper = useSelectionHelper(selection, ds, undefined, "always clear"); - const selectHelper = useSelectActionHelper( - { - itemSelection: selection, - itemSelectionMethod: selectionMethod, - itemSelectionMode: "clear", - showSelectAllToggle: false, - pageSize: 5 - }, - helper - ); - const cellEventsController = useCellEventsController( - selectHelper, - new ClickActionHelper("single", null), - props.focusController - ); - - const checkboxEventsController = useCheckboxEventsController(selectHelper, props.focusController); - - const contextValue = { - basicData: { - gridInteractive: true, - selectionStatus: helper?.type === "Multi" ? helper.selectionStatus : "unknown" - } as unknown as GridBasicData, - selectionHelper: helper, - selectActionHelper: selectHelper, - cellEventsController, - checkboxEventsController, - focusController: props.focusController, - selectionCountStore: {} as unknown as SelectionCounterViewModel - }; - - return ( - - - - ); - } - - function setup( - jsx: ReactElement - ): ReturnType & { rows: HTMLElement[]; user: ReturnType } { - const result = render(jsx); - const user = userEvent.setup(); - const rows = screen.getAllByRole("row").slice(1); - - return { - user, - rows, - ...result - }; - } - - beforeEach(() => { - ds = list(20); - items = ds.items!; - props = mockWidgetProps(); - selection = new SelectionMultiValueBuilder().build(); - props.data = items; - const columns = [ - column("Name"), - column("Description"), - column("Amount", col => { - col.showContentAs = "customContent"; - col.content = listWidget(() => ); - }) - ].map((col, index) => mockGridColumn(col, index)); - - props.visibleColumns = columns; - props.availableColumns = columns; - }); - - it("selects multiple rows with shift+click on a row", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - await user.click(rows[10].children[2]); - expect(selection.selection).toEqual([items[10]]); - - await user.keyboard("[ShiftLeft>]"); - - await user.click(rows[14].children[2]); - expect(selection.selection).toHaveLength(5); - expect(selection.selection).toEqual(items.slice(10, 15)); - - await user.click(rows[4].children[2]); - expect(selection.selection).toHaveLength(7); - expect(selection.selection).toEqual(items.slice(4, 11)); - - await user.click(rows[8].children[2]); - expect(selection.selection).toHaveLength(3); - expect(selection.selection).toEqual(items.slice(8, 11)); - }); - - it("selects multiple rows with shift+click on a checkbox", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - await user.click(getByRole(rows[10], "checkbox")); - expect(selection.selection).toEqual([items[10]]); - - await user.keyboard("[ShiftLeft>]"); - - await user.click(getByRole(rows[14], "checkbox")); - expect(selection.selection).toHaveLength(5); - expect(selection.selection).toEqual(items.slice(10, 15)); - - await user.click(getByRole(rows[4], "checkbox")); - expect(selection.selection).toHaveLength(7); - expect(selection.selection).toEqual(items.slice(4, 11)); - - await user.click(getByRole(rows[8], "checkbox")); - expect(selection.selection).toHaveLength(3); - expect(selection.selection).toEqual(items.slice(8, 11)); - }); - - it("selects all available rows with metaKey+a and method checkbox", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [checkbox] = getAllByRole(row, "checkbox"); - await user.click(checkbox); - expect(checkbox).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Meta>}a{/Meta}"); - expect(selection.selection).toHaveLength(20); - }); - - it("selects all available rows with metaKey+a and method rowClick", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [cell] = getAllByRole(row, "gridcell"); - await user.click(cell); - expect(cell).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Meta>}a{/Meta}"); - expect(selection.selection).toHaveLength(20); - }); - - it("selects all available rows with ctrlKey+a and method checkbox", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [checkbox] = getAllByRole(row, "checkbox"); - await user.click(checkbox); - expect(checkbox).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Control>}a{/Control}"); - expect(selection.selection).toHaveLength(20); - }); - - it("selects all available rows with ctrlKey+a and method rowClick", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [row] = rows; - const [cell] = getAllByRole(row, "gridcell"); - await user.click(cell); - expect(cell).toHaveFocus(); - expect(selection.selection).toHaveLength(1); - - await user.keyboard("{Control>}a{/Control}"); - expect(selection.selection).toHaveLength(20); - }); - - it("must not select rows, when metaKey+a or ctrlKey+a pressed in custom widget", async () => { - const { rows, user } = setup(); - - expect(rows).toHaveLength(20); - - const [input] = screen.getAllByRole("textbox"); - await user.click(input); - await user.keyboard("Hello, world!"); - expect(selection.selection).toHaveLength(0); - - await user.keyboard("{Control>}a{/Control}"); - expect(selection.selection).toHaveLength(0); - - await user.keyboard("{Meta>}a{/Meta}"); - expect(selection.selection).toHaveLength(0); - }); - }); - - describe("when has interactive element", () => { - it("should not prevent default on keyboard input (space and Enter)", async () => { - const items = objectItems(3); - - const props = mockWidgetProps(); - const content = listWidget(() =>