{children}
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 (
-
+
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx
new file mode 100644
index 0000000000..fd42081e81
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Grid.spec.tsx
@@ -0,0 +1,33 @@
+import { render } from "@testing-library/react";
+import { ContainerProvider } from "brandi-react";
+import { createDatagridContainer } from "../../model/containers/createDatagridContainer";
+import { mockContainerProps } from "../../utils/test-utils";
+import { Grid } from "../Grid";
+
+describe("Grid", () => {
+ it("renders without crashing", () => {
+ const props = mockContainerProps();
+ const [container] = createDatagridContainer(props);
+ const { asFragment } = render(
+
+ Test
+
+ );
+
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it("renders without selector column", () => {
+ const [container] = createDatagridContainer({
+ ...mockContainerProps(),
+ columnsHidable: false
+ });
+ const { asFragment } = render(
+
+ Test
+
+ );
+
+ expect(asFragment()).toMatchSnapshot();
+ });
+});
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 8f9f717f6f..0000000000
--- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx
+++ /dev/null
@@ -1,691 +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(),
- emptyPlaceholderRenderer: renderWrapper => renderWrapper()
- });
-
- 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(() => );
- const columns = Array.from(["Monday", "Tuesday", "Wednesday"], header => {
- const c = column(header);
- c.showContentAs = "customContent";
- c.content = content;
- return c;
- }).map((col, index) => mockGridColumn(col, index));
-
- props.visibleColumns = columns;
- props.availableColumns = columns;
-
- const user = userEvent.setup();
-
- renderWithRootContext({ ...props, data: items });
-
- const [input] = screen.getAllByRole("textbox");
- await user.click(input);
- await user.keyboard("Hello...{Enter}{Enter}is it me you're looking for?");
- expect(input).toHaveValue("Hello...\n\nis it me you're looking for?");
- });
- });
-});
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap
new file mode 100644
index 0000000000..a6e74b1f27
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Grid.spec.tsx.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Grid renders without crashing 1`] = `
+
+
+ Test
+
+
+`;
+
+exports[`Grid renders without selector column 1`] = `
+
+
+ Test
+
+
+`;
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap
deleted file mode 100644
index 9b26eb9fe5..0000000000
--- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap
+++ /dev/null
@@ -1,1926 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Table renders the structure correctly 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly for preview when no header is provided 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with column alignments 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with custom filtering 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with dragging 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with dynamic row class 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with empty placeholder 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with filtering 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with header filters and a11y 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with header wrapper 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with hiding 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with paging 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with resizing 1`] = `
-
-
-
-`;
-
-exports[`Table renders the structure correctly with sorting 1`] = `
-
-
-
-`;
-
-exports[`Table with selection method checkbox render an extra column and add class to each selected row 1`] = `
-
-
-
-`;
-
-exports[`Table with selection method rowClick add class to each selected cell 1`] = `
-
-
-
-`;
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx
index a784220308..a7039fe8a5 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx
@@ -1,5 +1,5 @@
import { Fragment, ReactElement } from "react";
-import { useLegacyContext } from "../../helpers/root-context";
+import { useDatagridConfig } from "../../model/hooks/injection-hooks";
import { CellElement } from "../CellElement";
import { SelectorCell } from "../SelectorCell";
import { SkeletonLoader } from "./SkeletonLoader";
@@ -17,14 +17,14 @@ export function RowSkeletonLoader({
pageSize,
useBorderTop = true
}: RowSkeletonLoaderProps): ReactElement {
- const { selectActionHelper } = useLegacyContext();
+ const { checkboxColumnEnabled } = useDatagridConfig();
return (
{Array.from({ length: pageSize }).map((_, i) => {
const borderTop = useBorderTop && i === 0;
return (
- {selectActionHelper.showCheckboxColumn && (
+ {checkboxColumnEnabled && (
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/base/WidgetRoot.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/base/WidgetRoot.viewModel.ts
new file mode 100644
index 0000000000..0ac435dfab
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/features/base/WidgetRoot.viewModel.ts
@@ -0,0 +1,56 @@
+import { TaskProgressService } from "@mendix/widget-plugin-grid/main";
+import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
+import { makeAutoObservable } from "mobx";
+import { createRef, CSSProperties } from "react";
+import { type SelectionMethod } from "../row-interaction/base";
+
+export class WidgetRootViewModel {
+ ref = createRef
();
+
+ constructor(
+ private gate: DerivedPropsGate<{
+ style?: CSSProperties;
+ class?: string;
+ }>,
+ private config: { selectionEnabled: boolean; selectionMethod: SelectionMethod },
+ private exportTask: TaskProgressService,
+ private selectAllVM: { isOpen: boolean }
+ ) {
+ makeAutoObservable(this);
+ }
+
+ get className(): string | undefined {
+ return this.gate.props.class;
+ }
+
+ get exporting(): boolean {
+ return this.exportTask.inProgress;
+ }
+
+ get selecting(): boolean {
+ return this.selectAllVM.isOpen;
+ }
+
+ get selectable(): boolean {
+ return this.config.selectionEnabled;
+ }
+
+ get selectionMethod(): SelectionMethod {
+ return this.config.selectionMethod;
+ }
+
+ get style(): CSSProperties {
+ const style = { ...this.gate.props.style };
+
+ if (!this.ref.current) return style;
+
+ if (this.exporting || this.selecting) {
+ return {
+ ...style,
+ height: this.ref.current.offsetHeight
+ };
+ }
+
+ return style;
+ }
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportProgressDialog.tsx
new file mode 100644
index 0000000000..42d0101d98
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportProgressDialog.tsx
@@ -0,0 +1,24 @@
+import { ReactNode } from "react";
+import { ExportAlert } from "../../components/ExportAlert";
+import { PseudoModal } from "../../components/PseudoModal";
+import { useExportProgressService, useTexts } from "../../model/hooks/injection-hooks";
+
+export const ExportProgressDialog = function ExportProgressDialog(props: { onExportCancel?: () => void }): ReactNode {
+ const progressSrv = useExportProgressService();
+ const texts = useTexts();
+
+ if (!progressSrv.inProgress) return null;
+
+ return (
+
+
+
+ );
+};
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 (
+
+ );
+});
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..e164f1909d
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/features/empty-message/injection-hooks.ts
@@ -0,0 +1,4 @@
+import { createInjectionHooks } from "brandi-react";
+import { DG_TOKENS as DG } from "../../model/tokens";
+
+export const [useEmptyPlaceholderVM] = createInjectionHooks(DG.emptyPlaceholderVM);
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CellEventsController.ts b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CellEventsController.ts
index fc84562371..4116f85999 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CellEventsController.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CellEventsController.ts
@@ -1,17 +1,24 @@
+import { ClickEntry, ClickEventSwitch } from "@mendix/widget-plugin-grid/event-switch/ClickEventSwitch";
import { ElementEntry, ElementProps } from "@mendix/widget-plugin-grid/event-switch/base";
import { eventSwitch } from "@mendix/widget-plugin-grid/event-switch/event-switch";
-import { ObjectItem } from "mendix";
-import { useMemo } from "react";
-import { createActionHandlers } from "./action-handlers";
-import { CellContext } from "./base";
-import { createSelectHandlers } from "./select-handlers";
-import { SelectActionHelper } from "../../helpers/SelectActionHelper";
-import { SelectAdjacentFx, SelectAllFx, SelectFx } from "@mendix/widget-plugin-grid/selection";
import { ClickActionHelper, ExecuteActionFx } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper";
import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
import { FocusTargetFx } from "@mendix/widget-plugin-grid/keyboard-navigation/base";
+import {
+ SelectAdjacentFx,
+ SelectAllFx,
+ SelectFx,
+ SelectionMode,
+ SelectionType
+} from "@mendix/widget-plugin-grid/selection";
+import { ObjectItem } from "mendix";
+
+import { SelectActionsService } from "@mendix/widget-plugin-grid/main";
+import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main";
+import { createActionHandlers } from "./action-handlers";
+import { CellContext, SelectionMethod } from "./base";
import { createFocusTargetHandlers } from "./focus-target-handlers";
-import { ClickEntry, ClickEventSwitch } from "@mendix/widget-plugin-grid/event-switch/ClickEventSwitch";
+import { createSelectHandlers } from "./select-handlers";
export class CellEventsController {
constructor(
@@ -44,28 +51,34 @@ export class CellEventsController {
}
}
-export function useCellEventsController(
- selectHelper: SelectActionHelper,
+export function createCellEventsController(
+ config: {
+ selectionType: SelectionType;
+ selectionMethod: SelectionMethod;
+ selectionMode: SelectionMode;
+ },
+ selectActions: SelectActionsService,
+ focusController: FocusTargetController,
clickHelper: ClickActionHelper,
- focusController: FocusTargetController
+ pageSize: ComputedAtom
): CellEventsController {
- return useMemo(() => {
- const cellContextFactory = (item: ObjectItem): CellContext => ({
- item,
- pageSize: selectHelper.pageSize,
- selectionType: selectHelper.selectionType,
- selectionMethod: selectHelper.selectionMethod,
- selectionMode: selectHelper.selectionMode,
- clickTrigger: clickHelper.clickTrigger
- });
+ // Placeholder function, actual implementation will depend on the specific context and services available.
+ const cellContextFactory = (item: ObjectItem): CellContext => ({
+ type: "cell",
+ item,
+ pageSize: pageSize.get(),
+ selectionType: config.selectionType,
+ selectionMethod: config.selectionMethod,
+ selectionMode: config.selectionMode,
+ clickTrigger: clickHelper.clickTrigger
+ });
- return new CellEventsController(
- cellContextFactory,
- selectHelper.onSelect,
- selectHelper.onSelectAll,
- selectHelper.onSelectAdjacent,
- clickHelper.onExecuteAction,
- focusController.dispatch
- );
- }, [selectHelper, clickHelper, focusController]);
+ return new CellEventsController(
+ cellContextFactory,
+ selectActions.select,
+ selectActions.selectPage,
+ selectActions.selectAdjacent,
+ clickHelper.onExecuteAction,
+ focusController.dispatch
+ );
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CheckboxEventsController.ts b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CheckboxEventsController.ts
index a2239dd16a..559e720f25 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CheckboxEventsController.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/CheckboxEventsController.ts
@@ -1,14 +1,20 @@
import { ElementProps } from "@mendix/widget-plugin-grid/event-switch/base";
import { eventSwitch } from "@mendix/widget-plugin-grid/event-switch/event-switch";
-import { SelectAdjacentFx, SelectAllFx, SelectFx } from "@mendix/widget-plugin-grid/selection";
+import { FocusTargetFx } from "@mendix/widget-plugin-grid/keyboard-navigation/base";
+import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
+import { SelectActionsService } from "@mendix/widget-plugin-grid/main";
+import {
+ SelectAdjacentFx,
+ SelectAllFx,
+ SelectFx,
+ SelectionMode,
+ SelectionType
+} from "@mendix/widget-plugin-grid/selection";
+import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main";
import { ObjectItem } from "mendix";
-import { useMemo } from "react";
-import { SelectActionHelper } from "../../helpers/SelectActionHelper";
-import { CheckboxContext } from "./base";
+import { CheckboxContext, SelectionMethod } from "./base";
import { checkboxHandlers } from "./checkbox-handlers";
-import { FocusTargetFx } from "@mendix/widget-plugin-grid/keyboard-navigation/base";
import { createFocusTargetHandlers } from "./focus-target-handlers";
-import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
export class CheckboxEventsController {
constructor(
@@ -29,24 +35,29 @@ export class CheckboxEventsController {
);
}
-export function useCheckboxEventsController(
- selectHelper: SelectActionHelper,
- focusController: FocusTargetController
+export function creteCheckboxEventsController(
+ config: {
+ selectionType: SelectionType;
+ selectionMethod: SelectionMethod;
+ selectionMode: SelectionMode;
+ },
+ selectActions: SelectActionsService,
+ focusController: FocusTargetController,
+ pageSize: ComputedAtom
): CheckboxEventsController {
- return useMemo(() => {
- const contextFactory = (item: ObjectItem): CheckboxContext => ({
- item,
- pageSize: selectHelper.pageSize,
- selectionType: selectHelper.selectionType,
- selectionMethod: selectHelper.selectionMethod,
- selectionMode: selectHelper.selectionMode
- });
- return new CheckboxEventsController(
- contextFactory,
- selectHelper.onSelect,
- selectHelper.onSelectAll,
- selectHelper.onSelectAdjacent,
- focusController.dispatch
- );
- }, [selectHelper, focusController]);
+ const contextFactory = (item: ObjectItem): CheckboxContext => ({
+ type: "checkbox",
+ item,
+ pageSize: pageSize.get(),
+ selectionType: config.selectionType,
+ selectionMethod: config.selectionMethod,
+ selectionMode: config.selectionMode
+ });
+ return new CheckboxEventsController(
+ contextFactory,
+ selectActions.select,
+ selectActions.selectPage,
+ selectActions.selectAdjacent,
+ focusController.dispatch
+ );
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-keyboard.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-keyboard.spec.tsx
index fa894e411c..3193d3f973 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-keyboard.spec.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-keyboard.spec.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable jest/no-conditional-expect */
import { eventSwitch } from "@mendix/widget-plugin-grid/event-switch/event-switch";
import { ClickTrigger } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper";
import { SelectionType } from "@mendix/widget-plugin-grid/selection";
@@ -5,9 +6,9 @@ import { objectItems } from "@mendix/widget-plugin-test-utils";
import { render, RenderResult } from "@testing-library/react";
import userEvent, { UserEvent } from "@testing-library/user-event";
import { ReactElement } from "react";
-import { SelectionMethod } from "../../../helpers/SelectActionHelper";
+
import { createActionHandlers } from "../action-handlers";
-import { CellContext } from "../base";
+import { CellContext, SelectionMethod } from "../base";
import { createSelectHandlers } from "../select-handlers";
function setup(jsx: ReactElement): { user: UserEvent } & RenderResult {
@@ -41,6 +42,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: sm as SelectionMethod,
@@ -88,6 +90,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: "rowClick",
@@ -171,6 +174,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: "rowClick",
@@ -212,6 +216,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: "none",
@@ -235,6 +240,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: "none",
@@ -261,6 +267,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: "none",
@@ -302,6 +309,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod,
@@ -330,6 +338,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: "checkbox",
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-pointer.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-pointer.spec.tsx
index 7c699f388b..b75f38877a 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-pointer.spec.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/__tests__/cell-pointer.spec.tsx
@@ -1,12 +1,12 @@
+/* eslint-disable jest/no-conditional-expect */
import { eventSwitch } from "@mendix/widget-plugin-grid/event-switch/event-switch";
import { ClickTrigger } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper";
import { objectItems } from "@mendix/widget-plugin-test-utils";
import { render, RenderResult } from "@testing-library/react";
import userEvent, { UserEvent } from "@testing-library/user-event";
import { ReactElement } from "react";
-import { SelectionMethod } from "../../../helpers/SelectActionHelper";
import { createActionHandlers } from "../action-handlers";
-import { CellContext } from "../base";
+import { CellContext, SelectionMethod } from "../base";
import { createSelectHandlers } from "../select-handlers";
function setup(jsx: ReactElement): { user: UserEvent } & RenderResult {
@@ -40,6 +40,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: sm as SelectionMethod,
@@ -90,6 +91,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: sm as SelectionMethod,
@@ -142,6 +144,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: sm as SelectionMethod,
@@ -192,6 +195,7 @@ describe("grid cell", () => {
const props = eventSwitch(
(): CellContext => ({
+ type: "cell",
item,
pageSize: 10,
selectionMethod: sm as SelectionMethod,
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/base.ts b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/base.ts
index ad4685117c..05c0278feb 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/base.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/row-interaction/base.ts
@@ -1,9 +1,10 @@
+import { ClickTrigger } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper";
import { SelectionMode, SelectionType } from "@mendix/widget-plugin-grid/selection";
import { ObjectItem } from "mendix";
-import { ClickTrigger } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper";
-import { SelectionMethod } from "../../helpers/SelectActionHelper";
-interface BaseContext {
+export type SelectionMethod = "rowClick" | "checkbox" | "none";
+
+interface BaseEventContext {
item: ObjectItem;
pageSize: number;
selectionMethod: SelectionMethod;
@@ -11,8 +12,11 @@ interface BaseContext {
selectionMode: SelectionMode;
}
-export interface CellContext extends BaseContext {
+export interface CellContext extends BaseEventContext {
+ type: "cell";
clickTrigger: ClickTrigger;
}
-export interface CheckboxContext extends BaseContext {}
+export interface CheckboxContext extends BaseEventContext {
+ type: "checkbox";
+}
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/SelectionCounter.tsx b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx
index ce4e19983b..978025e56d 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/SelectionCounter.tsx
@@ -1,10 +1,10 @@
import { observer } from "mobx-react-lite";
-import { useLegacyContext } from "../../helpers/root-context";
+import { useSelectActions } from "../../model/hooks/injection-hooks";
import { useSelectionCounterViewModel } from "./injection-hooks";
export const SelectionCounter = observer(function SelectionCounter() {
const selectionCountStore = useSelectionCounterViewModel();
- const { selectActionHelper } = useLegacyContext();
+ const selectActions = useSelectActions();
return (
@@ -12,7 +12,7 @@ export const SelectionCounter = observer(function SelectionCounter() {
{selectionCountStore.selectedCountText}
|
-
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/helpers/SelectActionHelper.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts
deleted file mode 100644
index 9b4b28a056..0000000000
--- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-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";
-
-export class SelectActionHelper extends SelectActionHandler {
- pageSize: number;
- private _selectionMethod: ItemSelectionMethodEnum;
- private _showSelectAllToggle: boolean;
-
- constructor(
- selection: WidgetSelectionProperty,
- selectionHelper: SelectionHelper | undefined,
- _selectionMethod: ItemSelectionMethodEnum,
- _showSelectAllToggle: boolean,
- pageSize: number,
- private _selectionMode: SelectionMode
- ) {
- super(selection, selectionHelper);
- this._selectionMethod = _selectionMethod;
- this._showSelectAllToggle = _showSelectAllToggle;
- this.pageSize = pageSize;
- }
-
- get selectionMethod(): SelectionMethod {
- return this.selectionType === "None" ? "none" : this._selectionMethod;
- }
-
- get showCheckboxColumn(): boolean {
- return this.selectionMethod === "checkbox";
- }
-
- get showSelectAllToggle(): boolean {
- return this._showSelectAllToggle && this.selectionType === "Multi";
- }
-
- get selectionMode(): SelectionMode {
- return this.selectionMethod === "checkbox" ? "toggle" : this._selectionMode;
- }
-}
-
-export function useSelectActionHelper(
- props: Pick<
- DatagridContainerProps | DatagridPreviewProps,
- "itemSelection" | "itemSelectionMethod" | "showSelectAllToggle" | "pageSize" | "itemSelectionMode"
- >,
- selectionHelper?: SelectionHelper
-): SelectActionHelper {
- return useMemo(
- () =>
- new SelectActionHelper(
- props.itemSelection,
- selectionHelper,
- props.itemSelectionMethod,
- props.showSelectAllToggle,
- props.pageSize ?? 5,
- props.itemSelectionMode
- ),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [selectionHelper]
- );
-}
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts
deleted file mode 100644
index e5780ebda8..0000000000
--- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
-import { SelectionHelper } from "@mendix/widget-plugin-grid/selection";
-import { createContext, useContext } from "react";
-import { EventsController } from "../typings/CellComponent";
-import { SelectActionHelper } from "./SelectActionHelper";
-
-export interface LegacyRootScope {
- selectionHelper: SelectionHelper | undefined;
- selectActionHelper: SelectActionHelper;
- cellEventsController: EventsController;
- checkboxEventsController: EventsController;
- focusController: FocusTargetController;
-}
-
-export const LegacyContext = createContext(null);
-
-export const useLegacyContext = (): LegacyRootScope => {
- const contextValue = useContext(LegacyContext);
- if (!contextValue) {
- throw new Error("useDatagridRootScope must be used within a root context provider");
- }
- return contextValue;
-};
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts
index f53082d863..9dd2181505 100644
--- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts
@@ -10,6 +10,7 @@ type Props = Pick<
type Gate = DerivedPropsGate;
/** This is basic data class, just a props mapper. Don't add any state or complex logic. */
+/** @deprecated use `TextsService` instead */
export class GridBasicData {
private gate: Gate;
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts
index dbd3e2520f..78f70a245e 100644
--- a/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/helpers/useDataGridJSActions.ts
@@ -1,12 +1,12 @@
import { useOnClearSelectionEvent, useOnResetFiltersEvent } from "@mendix/widget-plugin-external-events/hooks";
-import { useDatagridConfig } from "../model/hooks/injection-hooks";
-import { SelectActionHelper } from "./SelectActionHelper";
+import { useDatagridConfig, useSelectActions } from "../model/hooks/injection-hooks";
-export function useDataGridJSActions(selectActionHelper?: SelectActionHelper): void {
+export function useDataGridJSActions(): void {
const info = useDatagridConfig();
+ const selectActions = useSelectActions();
useOnResetFiltersEvent(info.name, info.filtersChannelName);
useOnClearSelectionEvent({
widgetName: info.name,
- listener: () => selectActionHelper?.onClearSelection()
+ listener: () => selectActions.clearSelection()
});
}
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..1ce8da4b69 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,7 @@
+import { SelectionMode, SelectionType } from "@mendix/widget-plugin-grid/selection";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
-import { DatagridContainerProps } from "../../../typings/DatagridProps";
+import { DatagridContainerProps, LoadingTypeEnum, PagingPositionEnum } from "../../../typings/DatagridProps";
+import { type SelectionMethod } from "../../features/row-interaction/base";
/** Config for static values that don't change at runtime. */
export interface DatagridConfig {
@@ -10,14 +12,28 @@ export interface DatagridConfig {
refreshIntervalMs: number;
selectAllCheckboxEnabled: boolean;
selectionEnabled: boolean;
+ selectionType: SelectionType;
+ selectionMethod: SelectionMethod;
+ selectionMode: SelectionMode;
selectorColumnEnabled: boolean;
settingsStorageEnabled: boolean;
+ enableSelectAll: boolean;
+ keepSelection: boolean;
+ pagingPosition: PagingPositionEnum;
+ multiselectable: true | undefined;
+ loadingType: LoadingTypeEnum;
+ columnsDraggable: boolean;
+ columnsFilterable: boolean;
+ columnsHidable: boolean;
+ columnsResizable: boolean;
+ columnsSortable: boolean;
+ isInteractive: boolean;
}
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,
@@ -25,9 +41,30 @@ export function datagridConfig(props: DatagridContainerProps): DatagridConfig {
refreshIntervalMs: props.refreshInterval * 1000,
selectAllCheckboxEnabled: props.showSelectAllToggle,
selectionEnabled: isSelectionEnabled(props),
+ selectionType: selectionType(props),
+ selectionMethod: selectionMethod(props),
+ selectionMode: props.itemSelectionMode,
selectorColumnEnabled: props.columnsHidable,
- settingsStorageEnabled: isSettingsStorageEnabled(props)
- });
+ settingsStorageEnabled: isSettingsStorageEnabled(props),
+ enableSelectAll: props.enableSelectAll,
+ keepSelection: props.keepSelection,
+ pagingPosition: props.pagingPosition,
+ multiselectable: isMultiselectable(props),
+ loadingType: props.loadingType,
+ columnsHidable: props.columnsHidable,
+ columnsDraggable: props.columnsDraggable,
+ columnsFilterable: props.columnsFilterable,
+ columnsResizable: props.columnsResizable,
+ columnsSortable: props.columnsSortable,
+ isInteractive: isInteractive(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 {
@@ -44,3 +81,15 @@ function isSettingsStorageEnabled(props: DatagridContainerProps): boolean {
if (props.configurationStorageType === "attribute" && props.configurationAttribute) return true;
return false;
}
+
+function isInteractive(props: DatagridContainerProps): boolean {
+ return props.itemSelection !== undefined || props.onClick !== undefined;
+}
+
+function selectionType(props: DatagridContainerProps): SelectionType {
+ return props.itemSelection ? props.itemSelection.type : "None";
+}
+
+function selectionMethod(props: DatagridContainerProps): SelectionMethod {
+ return props.itemSelection ? props.itemSelectionMethod : "none";
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/configs/__tests__/config.spec.ts b/packages/pluggableWidgets/datagrid-web/src/model/configs/__tests__/config.spec.ts
new file mode 100644
index 0000000000..207fc69827
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/configs/__tests__/config.spec.ts
@@ -0,0 +1,84 @@
+import { mockContainerProps } from "../../../utils/test-utils";
+import { datagridConfig } from "../Datagrid.config";
+
+describe("datagridConfig", () => {
+ it("should generate config with default values", () => {
+ const props = mockContainerProps();
+ const config = datagridConfig(props);
+
+ expect(config).toMatchObject({
+ checkboxColumnEnabled: false,
+ columnsDraggable: true,
+ columnsFilterable: true,
+ columnsHidable: true,
+ columnsResizable: true,
+ columnsSortable: true,
+ enableSelectAll: false,
+ filtersChannelName: expect.stringMatching(/^datagrid2_1:Datagrid@\w+:events$/),
+ id: expect.stringMatching(/^datagrid2_1:Datagrid@\w+$/),
+ keepSelection: false,
+ loadingType: "spinner",
+ multiselectable: undefined,
+ name: "datagrid2_1",
+ pagingPosition: "bottom",
+ refreshIntervalMs: 0,
+ selectAllCheckboxEnabled: true,
+ selectionEnabled: false,
+ selectorColumnEnabled: true,
+ settingsStorageEnabled: false
+ });
+ });
+
+ it("should generate config with multi-selection enabled", () => {
+ const props = {
+ ...mockContainerProps(),
+ itemSelection: { type: "Multi" } as any,
+ itemSelectionMethod: "checkbox" as const,
+ enableSelectAll: true,
+ keepSelection: true,
+ refreshInterval: 5
+ };
+ const config = datagridConfig(props);
+
+ expect(config).toMatchObject({
+ checkboxColumnEnabled: true,
+ enableSelectAll: true,
+ keepSelection: true,
+ multiselectable: true,
+ refreshIntervalMs: 5000,
+ selectionEnabled: true
+ });
+ });
+
+ it("should enable settings storage with localStorage", () => {
+ const props = {
+ ...mockContainerProps(),
+ configurationStorageType: "localStorage" as const
+ };
+ const config = datagridConfig(props);
+
+ expect(config.settingsStorageEnabled).toBe(true);
+ });
+
+ it("should enable settings storage with attribute when configurationAttribute is provided", () => {
+ const props = {
+ ...mockContainerProps(),
+ configurationStorageType: "attribute" as const,
+ configurationAttribute: { type: "attribute" } as any
+ };
+ const config = datagridConfig(props);
+
+ expect(config.settingsStorageEnabled).toBe(true);
+ });
+
+ it("should disable settings storage with attribute when configurationAttribute is not provided", () => {
+ const props = {
+ ...mockContainerProps(),
+ configurationStorageType: "attribute" as const,
+ configurationAttribute: undefined
+ };
+ const config = datagridConfig(props);
+
+ expect(config.settingsStorageEnabled).toBe(false);
+ });
+});
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..fd99730e77 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
@@ -1,129 +1,208 @@
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 { DatasourceService, ProgressService, SelectionCounterViewModel } from "@mendix/widget-plugin-grid/main";
-import { ClosableGateProvider } from "@mendix/widget-plugin-mobx-kit/ClosableGateProvider";
+import { emptyStateWidgetsAtom } from "@mendix/widget-plugin-grid/core/models/empty-state.model";
+import {
+ createClickActionHelper,
+ createFocusController,
+ createSelectionHelper,
+ DatasourceService,
+ layoutAtom,
+ SelectActionsProvider,
+ 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 { WidgetRootViewModel } from "../../features/base/WidgetRoot.viewModel";
+import { EmptyPlaceholderViewModel } from "../../features/empty-message/EmptyPlaceholder.viewModel";
+import { createCellEventsController } from "../../features/row-interaction/CellEventsController";
+import { creteCheckboxEventsController } from "../../features/row-interaction/CheckboxEventsController";
+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 { DatagridConfig } from "../configs/Datagrid.config";
+import { gridStyleAtom } from "../models/grid.model";
+import { rowClassProvider } from "../models/rows.model";
import { DatasourceParamsController } from "../services/DatasourceParamsController";
import { DerivedLoaderController } from "../services/DerivedLoaderController";
import { PaginationController } from "../services/PaginationController";
-import { TOKENS } from "../tokens";
+import { SelectionGate } from "../services/SelectionGate.service";
+import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../tokens";
+
+// base
+injected(ColumnGroupStore, CORE.setupService, CORE.mainGate, CORE.config, DG.filterHost);
+injected(DatasourceParamsController, CORE.setupService, DG.query, DG.combinedFilter, CORE.columnsStore);
+injected(DatasourceService, CORE.setupService, DG.queryGate, DG.refreshInterval.optional);
+injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query);
+injected(GridBasicData, CORE.mainGate);
+injected(WidgetRootViewModel, CORE.mainGate, CORE.config, DG.exportProgressService, SA_TOKENS.selectionDialogVM);
+
+// loader
+injected(DerivedLoaderController, DG.query, DG.exportProgressService, CORE.columnsStore, DG.loaderConfig);
+
+// filtering
+injected(CombinedFilter, CORE.setupService, DG.combinedFilterConfig);
+injected(WidgetFilterAPI, DG.parentChannelName, DG.filterHost);
+
+// empty state
+injected(emptyStateWidgetsAtom, CORE.mainGate, CORE.atoms.itemCount);
+injected(EmptyPlaceholderViewModel, DG.emptyPlaceholderWidgets, CORE.atoms.visibleColumnsCount, CORE.config);
+
+// personalization
+injected(GridPersonalizationStore, CORE.setupService, CORE.mainGate, CORE.columnsStore, DG.filterHost);
+
+// selection
+injected(SelectionGate, CORE.mainGate);
+injected(createSelectionHelper, CORE.setupService, DG.selectionGate, CORE.config.optional);
+injected(gridStyleAtom, CORE.columnsStore, CORE.config);
+injected(rowClassProvider, CORE.mainGate);
+
+// row-interaction
+injected(SelectActionsProvider, DG.selectionType, DG.selectionHelper);
+injected(createFocusController, CORE.setupService, DG.virtualLayout);
+injected(creteCheckboxEventsController, CORE.config, DG.selectActions, DG.focusService, CORE.atoms.pageSize);
+injected(layoutAtom, CORE.atoms.itemCount, CORE.atoms.columnCount, CORE.atoms.pageSize);
+injected(createClickActionHelper, CORE.setupService, CORE.mainGate);
+injected(
+ createCellEventsController,
+ CORE.config,
+ DG.selectActions,
+ DG.focusService,
+ DG.clickActionHelper,
+ CORE.atoms.pageSize
+);
+
+// selection counter
+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).inTransientScope();
+ 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();
+ // Selection helper
+ this.bind(DG.selectionHelper).toInstance(createSelectionHelper).inSingletonScope();
+ // Row class provider
+ this.bind(DG.rowClass).toInstance(rowClassProvider).inTransientScope();
+ // Widget root view model
+ this.bind(DG.datagridRootVM).toInstance(WidgetRootViewModel).inTransientScope();
+ // Select actions provider
+ this.bind(DG.selectActions).toInstance(SelectActionsProvider).inSingletonScope();
+ // Virtual layout
+ this.bind(DG.virtualLayout).toInstance(layoutAtom).inTransientScope();
+ // Focus service
+ this.bind(DG.focusService).toInstance(createFocusController).inSingletonScope();
+ // Checkbox events service
+ this.bind(DG.checkboxEventsHandler).toInstance(creteCheckboxEventsController).inSingletonScope();
+ // Cell events service
+ this.bind(DG.cellEventsHandler).toInstance(createCellEventsController).inSingletonScope();
+ // Click action helper
+ this.bind(DG.clickActionHelper).toInstance(createClickActionHelper).inSingletonScope();
+ }
- // 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.progressService).toConstant(selectAllModule.get(SA_TOKENS.progressService));
+ 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,
pageSize: props.pageSize
});
+ // Bind init page size
+ this.bind(CORE.initPageSize).toConstant(props.pageSize);
+
// Bind selection counter position
- this.bind(TOKENS.selectionCounterPosition).toConstant(props.selectionCounterPosition);
+ this.bind(DG.selectionCounterCfg).toConstant({ position: props.selectionCounterPosition });
- // Bind select all enabled flag
- this.bind(TOKENS.enableSelectAll).toConstant(props.enableSelectAll);
+ // Bind selection type
+ this.bind(DG.selectionType).toConstant(config.selectionType);
this.postInit(props, config);
@@ -131,20 +210,24 @@ 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);
+ }
+
+ 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(null);
}
// 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..30ee19b4b9 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,90 @@
+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 { columnCount, visibleColumnsCountAtom } from "../models/columns.model";
+import { pageSizeAtom } from "../models/paging.model";
+import { rowsAtom } from "../models/rows.model";
import { DatagridSetupService } from "../services/DatagridSetup.service";
-import { TOKENS } from "../tokens";
+import { TextsService } from "../services/Texts.service";
+import { PageSizeStore } from "../stores/PageSize.store";
+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);
+injected(rowsAtom, CORE.mainGate);
+injected(pageSizeAtom, CORE.pageSizeStore);
+injected(columnCount, CORE.atoms.visibleColumnsCount, CORE.config);
+
+// 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);
+injected(PageSizeStore, CORE.initPageSize.optional);
+
+// other
+injected(TextsService, CORE.mainGate);
/**
* 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 directly prop-dependent values here. Prop-derived atoms/stores via dependency injection are acceptable.
*/
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.isAllItemsPresent).toInstance(isAllItemsPresentAtom).inTransientScope();
+ this.bind(CORE.rows).toInstance(rowsAtom).inTransientScope();
+
+ // columns
+ this.bind(CORE.atoms.visibleColumnsCount).toInstance(visibleColumnsCountAtom).inTransientScope();
+ this.bind(CORE.atoms.columnCount).toInstance(columnCount).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();
+ this.bind(CORE.texts).toInstance(TextsService).inTransientScope();
+
+ // paging
+ this.bind(CORE.atoms.pageSize).toInstance(pageSizeAtom).inTransientScope();
+ this.bind(CORE.pageSizeStore).toInstance(PageSizeStore).inSingletonScope();
}
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/__tests__/createDatagridContainer.spec.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/__tests__/createDatagridContainer.spec.ts
new file mode 100644
index 0000000000..647b5c8e28
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/__tests__/createDatagridContainer.spec.ts
@@ -0,0 +1,58 @@
+import { list } from "@mendix/widget-plugin-test-utils";
+import { SelectAllModule } from "../../../features/select-all/SelectAllModule.container";
+import { mockContainerProps } from "../../../utils/test-utils";
+import { MainGateProvider } from "../../services/MainGateProvider.service";
+import { CORE_TOKENS as CORE } from "../../tokens";
+import { createDatagridContainer } from "../createDatagridContainer";
+import { DatagridContainer } from "../Datagrid.container";
+
+describe("createDatagridContainer", () => {
+ it("should create container with mock props", () => {
+ const [container, selectAllModule, mainProvider] = createDatagridContainer(mockContainerProps());
+
+ expect(container).toBeInstanceOf(DatagridContainer);
+ expect(selectAllModule).toBeInstanceOf(SelectAllModule);
+ expect(mainProvider).toBeInstanceOf(MainGateProvider);
+ });
+
+ it("should bind main provider gate to the container", () => {
+ const [container, , mainProvider] = createDatagridContainer(mockContainerProps());
+
+ expect(container.get(CORE.mainGate)).toBe(mainProvider.gate);
+ });
+
+ it("bind itemCount to computed value", () => {
+ const [container] = createDatagridContainer(mockContainerProps());
+
+ const itemsCount = container.get(CORE.atoms.itemCount);
+ expect(itemsCount.get()).toBe(5);
+ });
+
+ it("reacts to datasource changes in itemCount", () => {
+ const [container, , mainProvider] = createDatagridContainer(mockContainerProps());
+
+ const itemsCount = container.get(CORE.atoms.itemCount);
+
+ expect(itemsCount.get()).toBe(5);
+
+ mainProvider.setProps({
+ ...mockContainerProps(),
+ datasource: list(10)
+ });
+
+ expect(itemsCount.get()).toBe(10);
+ });
+
+ it("bind dg config to the container", () => {
+ const props = mockContainerProps();
+ const [container] = createDatagridContainer(props);
+
+ const config = container.get(CORE.config);
+ expect(config).toBeDefined();
+ expect(config).toMatchObject({
+ name: "datagrid2_1",
+ refreshIntervalMs: 0,
+ selectionEnabled: false
+ });
+ });
+});
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/createDatagridContainer.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/createDatagridContainer.ts
new file mode 100644
index 0000000000..afc94a9b8a
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/createDatagridContainer.ts
@@ -0,0 +1,34 @@
+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 { MainGateProvider } from "../services/MainGateProvider.service";
+import { DatagridContainer } from "./Datagrid.container";
+import { RootContainer } from "./Root.container";
+
+export function createDatagridContainer(
+ props: DatagridContainerProps
+): [DatagridContainer, SelectAllModule, MainGateProvider] {
+ const root = new RootContainer();
+
+ 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, mainProvider];
+}
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..337a03637f 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,26 @@
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);
+export const [useSelectionHelper] = createInjectionHooks(DG.selectionHelper);
+export const [useGridStyle] = createInjectionHooks(DG.gridColumnsStyle);
+export const [useQueryService] = createInjectionHooks(DG.query);
+export const [useVisibleColumnsCount] = createInjectionHooks(CORE.atoms.visibleColumnsCount);
+export const [useItemCount] = createInjectionHooks(CORE.atoms.itemCount);
+export const [useColumn] = createInjectionHooks(CORE.column);
+export const [useTexts] = createInjectionHooks(CORE.texts);
+export const [useRowClass] = createInjectionHooks(DG.rowClass);
+export const [useDatagridRootVM] = createInjectionHooks(DG.datagridRootVM);
+export const [useRows] = createInjectionHooks(CORE.rows);
+export const [useSelectActions] = createInjectionHooks(DG.selectActions);
+export const [useClickActionHelper] = createInjectionHooks(DG.clickActionHelper);
+export const [useFocusService] = createInjectionHooks(DG.focusService);
+export const [useCheckboxEventsHandler] = createInjectionHooks(DG.checkboxEventsHandler);
+export const [useCellEventsHandler] = createInjectionHooks(DG.cellEventsHandler);
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts
index d82c8bf1fe..c259ffbf3f 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts
@@ -3,26 +3,18 @@ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup";
import { Container } from "brandi";
import { useEffect } from "react";
import { DatagridContainerProps } from "../../../typings/DatagridProps";
-import { SelectAllModule } from "../../features/select-all/SelectAllModule.container";
-import { DatagridContainer } from "../containers/Datagrid.container";
-import { RootContainer } from "../containers/Root.container";
-import { TOKENS } from "../tokens";
+import { createDatagridContainer } from "../containers/createDatagridContainer";
+import { CORE_TOKENS as CORE } from "../tokens";
export function useDatagridContainer(props: DatagridContainerProps): Container {
- const [container, selectAllModule] = useConst(function init(): [DatagridContainer, SelectAllModule] {
- const root = new RootContainer();
- const selectAllModule = new SelectAllModule().init(props, root);
- const container = new DatagridContainer().init(props, root, selectAllModule);
-
- return [container, selectAllModule];
- });
+ const [container, selectAllModule, mainProvider] = useConst(() => createDatagridContainer(props));
// 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/models/__tests__/rows.model.spec.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/__tests__/rows.model.spec.ts
new file mode 100644
index 0000000000..d469574ee5
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/__tests__/rows.model.spec.ts
@@ -0,0 +1,54 @@
+import { listExpression, obj } from "@mendix/widget-plugin-test-utils";
+import { configure, isObservable, observable } from "mobx";
+import { MainGateProps } from "../../../../typings/MainGateProps";
+import { rowClassProvider } from "../rows.model";
+
+describe("rowClassProvider", () => {
+ configure({
+ enforceActions: "never"
+ });
+
+ it("returns empty string when rowClass is not defined", () => {
+ const gate = observable({ props: {} as MainGateProps });
+ const atom = rowClassProvider(gate);
+
+ expect(atom.class.get(obj())).toBe("");
+ });
+
+ it("returns the class from rowClass expression", () => {
+ const gate = observable({
+ props: {
+ rowClass: listExpression(() => "custom-row-class")
+ } as MainGateProps
+ });
+ const atom = rowClassProvider(gate);
+
+ expect(atom.class.get(obj())).toBe("custom-row-class");
+ });
+
+ it("updates reactively when rowClass expression changes", () => {
+ const rowClassExpression = listExpression(() => "initial-class");
+ const gate = observable({
+ props: {
+ rowClass: rowClassExpression
+ } as MainGateProps
+ });
+ const atom = rowClassProvider(gate);
+
+ expect(atom.class.get(obj())).toBe("initial-class");
+
+ gate.props.rowClass = listExpression(() => "updated-class");
+ expect(atom.class.get(obj())).toBe("updated-class");
+ });
+
+ it("class property is not observable itself", () => {
+ const gate = observable({
+ props: {
+ rowClass: listExpression(() => "some-class")
+ } as MainGateProps
+ });
+
+ const atom = rowClassProvider(gate);
+ expect(isObservable(atom.class)).toBe(false);
+ });
+});
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..cf9a56b5d6
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts
@@ -0,0 +1,19 @@
+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);
+}
+
+/** @injectable */
+export function columnCount(
+ visibleColumns: ComputedAtom,
+ config: { checkboxColumnEnabled: boolean }
+): ComputedAtom {
+ return computed(() => {
+ const count = visibleColumns.get();
+
+ return config.checkboxColumnEnabled ? count + 1 : count;
+ });
+}
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/models/paging.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/paging.model.ts
new file mode 100644
index 0000000000..b3c003f2d1
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/paging.model.ts
@@ -0,0 +1,6 @@
+import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main";
+import { computed } from "mobx";
+
+export function pageSizeAtom(source: { pageSize: number }): ComputedAtom {
+ return computed(() => source.pageSize);
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts
new file mode 100644
index 0000000000..2c11fc535c
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/rows.model.ts
@@ -0,0 +1,33 @@
+import { ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
+import { ObjectItem } from "mendix";
+import { computed, observable } from "mobx";
+import { MainGateProps } from "../../../typings/MainGateProps";
+
+export interface RowClassProvider {
+ class: {
+ get(item: ObjectItem): string;
+ };
+}
+
+/** @injectable */
+export function rowClassProvider(gate: DerivedPropsGate): RowClassProvider {
+ const atom = {
+ get class() {
+ return {
+ get(item: ObjectItem): string {
+ if (!gate.props.rowClass) return "";
+ return gate.props.rowClass.get(item).value ?? "";
+ }
+ };
+ }
+ };
+
+ return observable(atom, { class: computed });
+}
+
+/** @injectable */
+export function rowsAtom(gate: DerivedPropsGate): ComputedAtom {
+ return computed(() => {
+ return gate.props.datasource?.items ?? [];
+ });
+}
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..99552b2222
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/services/MainGateProvider.service.ts
@@ -0,0 +1,24 @@
+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();
+ }
+
+ /**
+ * @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;
+
+ super.setProps(props);
+ }
+}
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..9db5719f93
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/services/SelectionGate.service.ts
@@ -0,0 +1,17 @@
+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 {
+ return {
+ selection: props.itemSelection,
+ datasource: props.datasource,
+ onSelectionChange: props.onSelectionChange
+ };
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/services/Texts.service.ts b/packages/pluggableWidgets/datagrid-web/src/model/services/Texts.service.ts
new file mode 100644
index 0000000000..424ee99b2f
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/services/Texts.service.ts
@@ -0,0 +1,37 @@
+import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
+import { makeAutoObservable } from "mobx";
+import { MainGateProps } from "../../../typings/MainGateProps";
+
+export class TextsService {
+ constructor(private gate: DerivedPropsGate) {
+ makeAutoObservable(this);
+ }
+
+ private get props(): MainGateProps {
+ return this.gate.props;
+ }
+
+ get exportDialogLabel(): string | undefined {
+ return this.props.exportDialogLabel?.value;
+ }
+
+ get cancelExportLabel(): string | undefined {
+ return this.props.cancelExportLabel?.value;
+ }
+
+ get selectRowLabel(): string | undefined {
+ return this.props.selectRowLabel?.value;
+ }
+
+ get selectAllRowsLabel(): string | undefined {
+ return this.props.selectAllRowsLabel?.value;
+ }
+
+ get headerAriaLabel(): string | undefined {
+ return this.props.filterSectionTitle?.value;
+ }
+
+ get loadMoreButtonCaption(): string | undefined {
+ return this.props.loadMoreButtonCaption?.value;
+ }
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/stores/PageSize.store.ts b/packages/pluggableWidgets/datagrid-web/src/model/stores/PageSize.store.ts
new file mode 100644
index 0000000000..763d63d0cb
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/stores/PageSize.store.ts
@@ -0,0 +1,14 @@
+import { action, makeAutoObservable } from "mobx";
+
+export class PageSizeStore {
+ pageSize: number;
+
+ constructor(initSize = 0) {
+ this.pageSize = initSize;
+ makeAutoObservable(this, { setPageSize: action.bound });
+ }
+
+ setPageSize(size: number): void {
+ this.pageSize = size;
+ }
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
index 76b7510f47..26b3e38201 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
@@ -1,98 +1,149 @@
-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 { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper";
+import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
+import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout";
import {
- DatasourceService,
QueryService,
+ SelectActionsService,
SelectAllService,
- SelectionCounterViewModel,
+ SelectionDynamicProps,
+ SelectionHelperService,
TaskProgressService
} from "@mendix/widget-plugin-grid/main";
-import { DerivedPropsGate, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
-import { injected, token } from "brandi";
-import { ListValue } from "mendix";
-import { SelectionCounterPositionEnum } from "../../typings/DatagridProps";
+import { SelectAllFeature } from "@mendix/widget-plugin-grid/select-all/select-all.feature";
+import {
+ 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, ObjectItem } from "mendix";
+import { CSSProperties, ReactNode } from "react";
import { MainGateProps } from "../../typings/MainGateProps";
+import { WidgetRootViewModel } from "../features/base/WidgetRoot.viewModel";
+import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel";
+import { CellEventsController } from "../features/row-interaction/CellEventsController";
+import { CheckboxEventsController } from "../features/row-interaction/CheckboxEventsController";
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 { GridColumn } from "../typings/GridColumn";
import { DatagridConfig } from "./configs/Datagrid.config";
+import { RowClassProvider } from "./models/rows.model";
+import { DatagridSetupService } from "./services/DatagridSetup.service";
import { DerivedLoaderController, DerivedLoaderControllerConfig } from "./services/DerivedLoaderController";
import { PaginationConfig, PaginationController } from "./services/PaginationController";
+import { TextsService } from "./services/Texts.service";
+import { PageSizeStore } from "./stores/PageSize.store";
-/** 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"),
+ pageSize: token>("@computed:pageSize"),
+ columnCount: token>("@computed:columnCount")
+ },
columnsStore: token("ColumnGroupStore"),
+ initPageSize: token("@const:initialPageSize"),
+ pageSizeStore: token("@store:PageSizeStore"),
+ column: token("@store:GridColumn"),
+ rows: token>("@computed:rowsArray"),
+
+ config: token("DatagridConfig"),
+
+ mainGate: token>("@gate: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"),
+
+ texts: token("@srv:TextsService")
+};
+
+/** Datagrid tokens. */
+export const DG_TOKENS = {
+ basicDate: token("GridBasicData"),
+
combinedFilter: token("CombinedFilter"),
combinedFilterConfig: token("CombinedFilterKey"),
- config: token("DatagridConfig"),
- enableSelectAll: token("enableSelectAll"),
+
+ emptyPlaceholderVM: token("EmptyPlaceholderViewModel"),
+ emptyPlaceholderWidgets: token>("@computed:emptyPlaceholder"),
+
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"),
- personalizationService: token("GridPersonalizationStore"),
- query: token("QueryService"),
- queryGate: token>("GateForQueryService"),
refreshInterval: token("refreshInterval"),
- selectAllBarVM: token("SelectAllBarViewModel"),
- selectAllGate: token>("GateForSelectAllService"),
- selectAllProgressService: token("SelectAllProgressService"),
- selectAllService: token("SelectAllService"),
- selectionCounterPosition: token("SelectionCounterPositionEnum"),
- selectionCounterVM: token("SelectionCounterViewModel"),
- selectionDialogVM: token("SelectionProgressDialogViewModel"),
- setupService: token("DatagridSetupHost")
-};
-
-/** 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);
+ paramsService: token("DatagridParamsService"),
+ personalizationService: token("GridPersonalizationStore"),
-injected(GridPersonalizationStore, TOKENS.setupService, TOKENS.mainGate, TOKENS.columnsStore, TOKENS.filterHost);
+ query: token("QueryService"),
+ queryGate: token>("@gate:GateForQueryService"),
-injected(PaginationController, TOKENS.setupService, TOKENS.paginationConfig, TOKENS.query);
+ selectionCounterCfg: token<{ position: "top" | "bottom" | "off" }>("SelectionCounterConfig"),
+ selectionCounterVM: token("SelectionCounterViewModel"),
-injected(DatasourceService, TOKENS.setupService, TOKENS.queryGate, TOKENS.refreshInterval.optional);
+ selectionGate: token>("@gate:GateForSelectionHelper"),
+ selectionHelper: token("@service:SelectionHelperService"),
+ selectActions: token("@service:SelectActionsService"),
+ selectionType: token<"Single" | "Multi" | "None">("@const:selectionType"),
-injected(DerivedLoaderController, TOKENS.query, TOKENS.exportProgressService, TOKENS.columnsStore, TOKENS.loaderConfig);
+ gridColumnsStyle: token>("@computed:GridColumnsStyle"),
-injected(SelectionCounterViewModel, TOKENS.mainGate, TOKENS.selectionCounterPosition);
+ rowClass: token("@store:RowClassProvider"),
-injected(SelectAllService, TOKENS.setupService, TOKENS.selectAllGate, TOKENS.query, TOKENS.selectAllProgressService);
+ datagridRootVM: token("WidgetRootViewModel"),
-injected(
- SelectAllBarViewModel,
- TOKENS.setupService,
- TOKENS.mainGate,
- TOKENS.selectAllService,
- TOKENS.selectionCounterVM,
- TOKENS.enableSelectAll
-);
+ virtualLayout: token>("@computed:virtualLayout"),
+ clickActionHelper: token("@service:ClickActionHelper"),
+ focusService: token("@service:FocusTargetController"),
+ checkboxEventsHandler: token("@service:CheckboxEventsController"),
+ cellEventsHandler: token("@service:CellEventsController")
+};
-injected(
- SelectionProgressDialogViewModel,
- TOKENS.setupService,
- TOKENS.mainGate,
- TOKENS.selectAllProgressService,
- TOKENS.selectAllService
-);
+/** "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"),
+ selectAllService: token("SelectAllService"),
+ selectionDialogVM: token("SelectionProgressDialogViewModel"),
+ enableSelectAll: token("enableSelectAllFeatureFlag"),
+ feature: token("SelectAllFeature")
+};
diff --git a/packages/pluggableWidgets/datagrid-web/src/typings/CellComponent.ts b/packages/pluggableWidgets/datagrid-web/src/typings/CellComponent.ts
index bc0f756450..15f1fd9e48 100644
--- a/packages/pluggableWidgets/datagrid-web/src/typings/CellComponent.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/typings/CellComponent.ts
@@ -1,23 +1,6 @@
-import { ObjectItem } from "mendix";
-import { ReactElement, ReactNode } from "react";
-import { GridColumn } from "./GridColumn";
import { ElementProps } from "@mendix/widget-plugin-grid/event-switch/base";
+import { ObjectItem } from "mendix";
export interface EventsController {
getProps(item: ObjectItem): ElementProps;
}
-
-export interface CellComponentProps {
- children?: ReactNode;
- className?: string;
- column: C;
- item: ObjectItem;
- key?: string | number;
- rowIndex: number;
- columnIndex?: number;
- clickable?: boolean;
- preview?: boolean;
- eventsController: EventsController;
-}
-
-export type CellComponent = (props: CellComponentProps) => ReactElement;
diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx
index 9fe9f153d5..d4e05edc9c 100644
--- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx
@@ -1,14 +1,8 @@
-import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
-import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController";
-import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout";
-import { dynamicValue, listAttr, listExp } from "@mendix/widget-plugin-test-utils";
-import { GUID, ObjectItem } from "mendix";
-import { ColumnsType } from "../../typings/DatagridProps";
-import { Cell } from "../components/Cell";
-import { WidgetProps } from "../components/Widget";
-import { SelectActionHelper } from "../helpers/SelectActionHelper";
+import { dynamic, list, listAttr, listExp } from "@mendix/widget-plugin-test-utils";
+import { ColumnsType, DatagridContainerProps } from "../../typings/DatagridProps";
import { ColumnStore } from "../helpers/state/column/ColumnStore";
import { IColumnParentStore } from "../helpers/state/ColumnGroupStore";
+import { SelectActionHelper } from "../model/services/GridSelectActionsProvider.service";
import { ColumnId, GridColumn } from "../typings/GridColumn";
export const column = (header = "Test", patch?: (col: ColumnsType) => void): ColumnsType => {
@@ -17,7 +11,7 @@ export const column = (header = "Test", patch?: (col: ColumnsType) => void): Col
attribute: listAttr(() => "Attr value"),
dynamicText: listExp(() => "Dynamic text"),
draggable: false,
- header: dynamicValue(header),
+ header: dynamic(header),
hidable: "no" as const,
resizable: false,
showContentAs: "attribute",
@@ -25,7 +19,7 @@ export const column = (header = "Test", patch?: (col: ColumnsType) => void): Col
sortable: false,
width: "autoFill" as const,
wrapText: false,
- visible: dynamicValue(true),
+ visible: dynamic(true),
minWidth: "auto",
minWidthLimit: 100,
allowEventPropagation: true,
@@ -68,49 +62,39 @@ export function mockGridColumn(c: ColumnsType, index: number): GridColumn {
return new ColumnStore(index, c, parentStore);
}
-export function mockWidgetProps(): WidgetProps {
- const id = "dg1";
- const columnsProp = [column("Test")];
- const columns = columnsProp.map((col, index) => mockGridColumn(col, index));
-
+export function mockContainerProps(overrides?: Partial): DatagridContainerProps {
return {
- CellComponent: Cell,
- className: "test",
- columnsDraggable: false,
- columnsFilterable: false,
- columnsHidable: false,
- columnsResizable: false,
- columnsSortable: false,
- data: [{ id: "123456" as GUID }],
- exporting: false,
- filterRenderer: () => ,
- hasMoreItems: false,
- headerWrapperRenderer: (_index, header) => header,
- id,
- onExportCancel: jest.fn(),
- page: 1,
+ class: "dg-one",
+ name: "datagrid2_1",
+ datasource: list(5),
+ refreshInterval: 0,
+ columnsFilterable: true,
+ columnsSortable: true,
+ columnsDraggable: true,
+ columnsHidable: true,
+ columnsResizable: true,
+ columns: [column("Col1"), column("Col2")],
+ itemSelectionMethod: "checkbox",
+ itemSelectionMode: "clear",
+ enableSelectAll: false,
+ keepSelection: false,
+ showSelectAllToggle: true,
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({}) },
- checkboxEventsController: { getProps: () => Object.create({}) },
- isFirstLoad: false,
- isFetchingNextBatch: false,
+ selectionCounterPosition: "bottom",
+ pagination: "buttons",
+ refreshIndicator: false,
loadingType: "spinner",
- columnsLoading: false,
- showRefreshIndicator: false,
- focusController: new FocusTargetController(
- new PositionController(),
- new VirtualGridLayout(1, columns.length, 10)
- )
+ showPagingButtons: "auto",
+ pagingPosition: "bottom",
+ onClickTrigger: "single",
+ showNumberOfRows: false,
+ showEmptyPlaceholder: "none",
+ configurationStorageType: "attribute",
+ configurationAttribute: undefined,
+ storeFiltersInPersonalization: true,
+ selectAllText: dynamic("Select all items"),
+ selectAllTemplate: dynamic("Select all %d items"),
+ allSelectedText: dynamic("All items selected"),
+ ...overrides
};
}
diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
index 6567fd1986..5e93b89aea 100644
--- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
+++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
@@ -3,27 +3,38 @@ import { DatagridContainerProps } from "./DatagridProps";
/** Type to declare props available through main gate. */
export type MainGateProps = Pick<
DatagridContainerProps,
- | "name"
- | "datasource"
- | "refreshInterval"
- | "refreshIndicator"
- | "itemSelection"
+ | "allSelectedText"
+ | "cancelExportLabel"
+ | "cancelSelectionLabel"
+ | "class"
+ | "clearSelectionButtonLabel"
| "columns"
- | "configurationStorageType"
- | "storeFiltersInPersonalization"
| "configurationAttribute"
+ | "configurationStorageType"
+ | "datasource"
+ | "emptyPlaceholder"
+ | "enableSelectAll"
+ | "exportDialogLabel"
+ | "filterSectionTitle"
+ | "filtersPlaceholder"
+ | "itemSelection"
+ | "loadMoreButtonCaption"
+ | "name"
+ | "onClick"
+ | "onClickTrigger"
+ | "onSelectionChange"
| "pageSize"
| "pagination"
- | "showPagingButtons"
- | "showNumberOfRows"
- | "clearSelectionButtonLabel"
+ | "refreshIndicator"
+ | "refreshInterval"
+ | "rowClass"
+ | "selectAllRowsLabel"
| "selectAllTemplate"
| "selectAllText"
- | "itemSelection"
- | "datasource"
- | "allSelectedText"
- | "selectAllRowsLabel"
- | "cancelSelectionLabel"
| "selectionCounterPosition"
- | "enableSelectAll"
+ | "selectRowLabel"
+ | "showNumberOfRows"
+ | "showPagingButtons"
+ | "storeFiltersInPersonalization"
+ | "style"
>;
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..23592fe084
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/core/__tests__/selectedCountMulti.spec.ts
@@ -0,0 +1,38 @@
+import { configure, observable } from "mobx";
+import { selectedCountMultiAtom } from "../models/selection.model.js";
+
+describe("selectedCountMulti", () => {
+ configure({
+ enforceActions: "never"
+ });
+
+ it("returns selection length when type is Multi", () => {
+ 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({ 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({ props: {} });
+ const atom = selectedCountMultiAtom(gate);
+ expect(atom.get()).toBe(-1);
+ });
+
+ it("updates reactively when selection changes", () => {
+ const gate = observable({ props: { itemSelection: { type: "Multi" as const, selection: [{ id: "1" }] } } });
+ const atom = selectedCountMultiAtom(gate);
+
+ expect(atom.get()).toBe(1);
+
+ gate.props.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..0a023e8e0b
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/core/models/selection.model.ts
@@ -0,0 +1,107 @@
+import { atomFactory, ComputedAtom, DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
+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 selectedCountMultiAtom(
+ gate: DerivedPropsGate<{
+ itemSelection?: Selection;
+ }>
+): ComputedAtom {
+ return computed(() => {
+ const { itemSelection } = gate.props;
+ if (itemSelection?.type === "Multi") {
+ return 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
+);
+
+/** 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?: Selection;
+ 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);
+ });
+}
+
+interface ObservableSelectorTexts {
+ clearSelectionButtonLabel: string;
+ selectedCountText: string;
+}
+
+export function selectionCounterTextsStore(
+ 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/helpers/createClickActionHelper.ts b/packages/shared/widget-plugin-grid/src/helpers/createClickActionHelper.ts
new file mode 100644
index 0000000000..8767f2b2af
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/helpers/createClickActionHelper.ts
@@ -0,0 +1,21 @@
+import { DerivedPropsGate, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
+import { ListActionValue } from "mendix";
+import { autorun } from "mobx";
+import { ClickActionHelper, ClickTrigger } from "./ClickActionHelper";
+
+export function createClickActionHelper(
+ host: SetupComponentHost,
+ gate: DerivedPropsGate<{ onClickTrigger: ClickTrigger; onClick?: ListActionValue }>
+): ClickActionHelper {
+ const helper = new ClickActionHelper(gate.props.onClickTrigger, gate.props.onClick);
+
+ function setup(): () => void {
+ return autorun(() => {
+ helper.update(gate.props.onClick);
+ });
+ }
+
+ host.add({ setup });
+
+ return helper;
+}
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/SelectActionsService.ts b/packages/shared/widget-plugin-grid/src/interfaces/SelectActionsService.ts
new file mode 100644
index 0000000000..bffd4e8e84
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/interfaces/SelectActionsService.ts
@@ -0,0 +1,11 @@
+import { ObjectItem } from "mendix";
+import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType } from "../selection";
+
+export interface SelectActionsService {
+ selectionType: SelectionType;
+ select: SelectFx;
+ selectPage: SelectAllFx;
+ selectAdjacent: SelectAdjacentFx;
+ isSelected(item: ObjectItem): boolean;
+ clearSelection(): 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..d13bb8341d
--- /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..deda64c275
--- /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 | null;
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/keyboard-navigation/createFocusController.ts b/packages/shared/widget-plugin-grid/src/keyboard-navigation/createFocusController.ts
new file mode 100644
index 0000000000..be46545700
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/keyboard-navigation/createFocusController.ts
@@ -0,0 +1,24 @@
+import { ComputedAtom, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
+import { reaction } from "mobx";
+import { FocusTargetController } from "./FocusTargetController";
+import { PositionController } from "./PositionController";
+import { VirtualGridLayout } from "./VirtualGridLayout";
+
+/** @injectable */
+export function createFocusController(
+ host: SetupComponentHost,
+ layout: ComputedAtom
+): FocusTargetController {
+ const controller = new FocusTargetController(new PositionController(), layout.get());
+
+ function setup(): void | (() => void) {
+ return reaction(
+ () => layout.get(),
+ newLayout => controller.updateGridLayout(newLayout)
+ );
+ }
+
+ host.add({ setup });
+
+ return controller;
+}
diff --git a/packages/shared/widget-plugin-grid/src/keyboard-navigation/layout.model.ts b/packages/shared/widget-plugin-grid/src/keyboard-navigation/layout.model.ts
new file mode 100644
index 0000000000..293f820d19
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/keyboard-navigation/layout.model.ts
@@ -0,0 +1,12 @@
+import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main";
+import { computed } from "mobx";
+import { VirtualGridLayout } from "./VirtualGridLayout";
+
+/** @injectable */
+export function layoutAtom(
+ rows: ComputedAtom,
+ columns: ComputedAtom,
+ pageSize: ComputedAtom
+): ComputedAtom {
+ return computed(() => new VirtualGridLayout(rows.get(), columns.get(), pageSize.get()));
+}
diff --git a/packages/shared/widget-plugin-grid/src/main.ts b/packages/shared/widget-plugin-grid/src/main.ts
index 2b90b358d6..7bb034257b 100644
--- a/packages/shared/widget-plugin-grid/src/main.ts
+++ b/packages/shared/widget-plugin-grid/src/main.ts
@@ -1,6 +1,15 @@
export { DatasourceService } from "./core/Datasource.service";
export { ProgressService } from "./core/Progress.service";
+export { createClickActionHelper } from "./helpers/createClickActionHelper";
export type { QueryService } from "./interfaces/QueryService";
+export type { SelectActionsService } from "./interfaces/SelectActionsService";
+export type { SelectionDynamicProps } from "./interfaces/SelectionDynamicProps";
+export { type SelectionHelperService } from "./interfaces/SelectionHelperService";
export type { TaskProgressService } from "./interfaces/TaskProgressService";
+export { createFocusController } from "./keyboard-navigation/createFocusController";
+export { layoutAtom } from "./keyboard-navigation/layout.model";
export { SelectAllService } from "./select-all/SelectAll.service";
export { SelectionCounterViewModel } from "./selection-counter/SelectionCounter.viewModel";
+export * from "./selection/context";
+export { createSelectionHelper } from "./selection/createSelectionHelper";
+export { SelectActionsProvider } from "./selection/SelectActionsProvider.service";
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..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
@@ -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 { SelectAllEvents } 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;
@@ -134,17 +118,15 @@ export class SelectAllService implements SetupComponent {
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.onloadend();
- // const selectionBeforeReload = this.selection?.selection ?? [];
- // Reload selection to make sure setSelection is working as expected.
+ this.progress.emit("loadend");
this.selection?.setSelection(success ? allItems : initSelection);
this.locked = false;
this.abortController = undefined;
@@ -152,6 +134,8 @@ export class SelectAllService implements SetupComponent {
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/SelectAllBar.store.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts
new file mode 100644
index 0000000000..58ecf9bc8a
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllBar.store.ts
@@ -0,0 +1,29 @@
+import { makeAutoObservable } from "mobx";
+import { BarStore } from "./select-all.model";
+
+export class SelectAllBarStore implements BarStore {
+ pending = false;
+ visible = false;
+ clearBtnVisible = false;
+
+ constructor() {
+ makeAutoObservable(this);
+ }
+
+ 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..3e8f02d5ba
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.feature.ts
@@ -0,0 +1,43 @@
+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();
+
+ 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
new file mode 100644
index 0000000000..f2d2adea55
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/select-all/select-all.model.ts
@@ -0,0 +1,159 @@
+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;
+ 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 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;
+ 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()));
+ add(() => 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/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..7719ceb394
--- /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 options: { position: "top" | "bottom" | "off" } = { position: "top" }
+ ) {
+ makeAutoObservable(this);
+ }
+
+ get isTopCounterVisible(): boolean {
+ if (this.options.position !== "top") return false;
+ return this.selected.get() > 0;
+ }
+
+ get isBottomCounterVisible(): boolean {
+ if (this.options.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..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
@@ -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, { position: "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, { position: "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, { position: "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, { position: "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, { position: "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, { position: "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, { position: "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, { position: "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, { position: "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, { position: "top" });
- // Clear selection
- multiSelection.setSelection([]);
- expect(selectionCountStore.selectedCount).toBe(0);
+ expect(viewModel.isBottomCounterVisible).toBe(false);
});
});
});
diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectActionsProvider.service.ts b/packages/shared/widget-plugin-grid/src/selection/SelectActionsProvider.service.ts
new file mode 100644
index 0000000000..5a42974096
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/selection/SelectActionsProvider.service.ts
@@ -0,0 +1,116 @@
+import { ObjectItem } from "mendix";
+import { SelectActionsService } from "../interfaces/SelectActionsService";
+import { SelectionHelperService } from "../interfaces/SelectionHelperService";
+import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType } from "./types";
+
+export class SelectActionsProvider implements SelectActionsService {
+ constructor(
+ private type: SelectionType,
+ private selectionHelper: SelectionHelperService
+ ) {
+ if (type === "Multi") {
+ if (!selectionHelper || selectionHelper.type !== "Multi") {
+ throw new Error("SelectionHelperService of type Multi is required for Multi selection type");
+ }
+ }
+ if (type === "Single") {
+ if (!selectionHelper || selectionHelper.type !== "Single") {
+ throw new Error("SelectionHelperService of type Single is required for Single selection type");
+ }
+ }
+ }
+
+ get selectionType(): SelectionType {
+ return this.type;
+ }
+
+ select: SelectFx = (...params) => {
+ if (!this.selectionHelper) {
+ return;
+ }
+
+ if (this.selectionHelper.type === "Multi") {
+ this.selectItemMulti(...params);
+ } else {
+ this.selectItemSingle(...params);
+ }
+ };
+
+ selectPage: SelectAllFx = (requestedAction?: "selectAll") => {
+ if (!this.selectionHelper) {
+ return;
+ }
+
+ if (this.selectionHelper.type === "Single") {
+ console.warn("Calling selectPage in single selection mode have no effect");
+ return;
+ }
+
+ if (requestedAction === "selectAll") {
+ this.selectionHelper.selectAll();
+ return;
+ }
+
+ if (this.selectionHelper.selectionStatus === "all") {
+ this.selectionHelper.selectNone();
+ } else {
+ this.selectionHelper.selectAll();
+ }
+ };
+
+ selectAdjacent: SelectAdjacentFx = (...params) => {
+ if (this.selectionHelper?.type === "Multi") {
+ this.selectionHelper.selectUpToAdjacent(...params);
+ }
+ };
+
+ isSelected = (item: ObjectItem): boolean => {
+ return this.selectionHelper ? this.selectionHelper.isSelected(item) : false;
+ };
+
+ clearSelection = (): void => {
+ if (this.selectionHelper?.type === "Multi") {
+ this.selectionHelper.clearSelection();
+ } else if (this.selectionHelper?.type === "Single") {
+ this.selectionHelper.remove();
+ }
+ };
+
+ private selectItemMulti: SelectFx = (item, shiftKey, toggleMode = false) => {
+ if (this.selectionHelper?.type !== "Multi") {
+ return;
+ }
+
+ if (shiftKey) {
+ this.selectionHelper.selectUpTo(item, toggleMode ? "toggle" : "clear");
+ return;
+ }
+
+ this.selectItem(item, toggleMode);
+ };
+
+ private selectItemSingle: SelectFx = (item, _, toggleMode = false) => {
+ this.selectItem(item, toggleMode);
+ };
+
+ private selectItem = (item: ObjectItem, toggleMode: boolean): void => {
+ if (this.selectionHelper == null) {
+ return;
+ }
+
+ // clear mode
+ if (toggleMode === false) {
+ this.selectionHelper.reduceTo(item);
+ return;
+ }
+
+ // toggle mode
+ if (this.selectionHelper.isSelected(item)) {
+ this.selectionHelper.remove(item);
+ } else if (this.selectionHelper.type === "Multi") {
+ this.selectionHelper.add(item);
+ } else {
+ this.selectionHelper.reduceTo(item);
+ }
+ };
+}
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..3902487856
--- /dev/null
+++ b/packages/shared/widget-plugin-grid/src/selection/createSelectionHelper.ts
@@ -0,0 +1,68 @@
+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 | null = null;
+
+ if (!selection) {
+ return null;
+ }
+
+ if (selection.type === "Multi") {
+ helper = new MultiSelectionHelper(selection, datasource.items ?? []);
+ } else if (selection.type === "Single") {
+ helper = new SingleSelectionHelper(selection);
+ }
+ if (config.keepSelection) {
+ selection?.setKeepSelection(() => true);
+ }
+
+ function setup(): (() => void) | void {
+ const [add, disposeAll] = disposeBatch();
+
+ if (!helper) return;
+
+ 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..c5bcccad46 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,12 @@
import { ObjectItem } from "mendix";
-import { SelectionHelper } from "./helpers";
+import { SelectionHelperService } from "../interfaces/SelectionHelperService";
import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType, WidgetSelectionProperty } from "./types";
+/** @deprecated use `SelectActionsProvider` instead */
export class SelectActionHandler {
constructor(
private selection: WidgetSelectionProperty,
- protected selectionHelper: SelectionHelper | undefined
+ protected selectionHelper: SelectionHelperService
) {}
get selectionType(): SelectionType {
@@ -92,7 +93,7 @@ export class SelectActionHandler {
};
private selectItem = (item: ObjectItem, toggleMode: boolean): void => {
- if (this.selectionHelper === undefined) {
+ if (this.selectionHelper == null) {
return;
}
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" });
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";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7774b684ae..f1e87986c0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,7 @@ overrides:
'@codemirror/state': ^6.5.2
'@codemirror/view': ^6.38.1
'@mendix/pluggable-widgets-tools': 10.21.2
+ '@testing-library/react': '>=15.0.6'
'@types/big.js': ^6.2.2
'@types/node': ~22.14.0
'@types/react': '>=18.2.36'
@@ -4703,12 +4704,20 @@ packages:
resolution: {integrity: sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==}
engines: {node: '>=8', npm: '>=6', yarn: '>=1'}
- '@testing-library/react@13.4.0':
- resolution: {integrity: sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==}
- engines: {node: '>=12'}
+ '@testing-library/react@16.3.0':
+ resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==}
+ engines: {node: '>=18'}
peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': '>=18.2.36'
+ '@types/react-dom': ^18.0.0 || ^19.0.0
react: '>=18.0.0 <19.0.0'
react-dom: '>=18.0.0 <19.0.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
@@ -12453,7 +12462,7 @@ snapshots:
'@rollup/pluginutils': 5.3.0(rollup@3.29.5)
'@testing-library/dom': 8.20.1
'@testing-library/jest-dom': 5.17.0
- '@testing-library/react': 13.4.0(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/react': 16.3.0(@testing-library/dom@8.20.1)(@types/react-dom@18.3.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@testing-library/user-event': 14.6.1(@testing-library/dom@8.20.1)
'@types/react': 19.2.2
'@types/react-dom': 18.3.7(@types/react@19.2.2)
@@ -12483,7 +12492,7 @@ snapshots:
identity-obj-proxy: 3.0.0
jasmine: 3.99.0
jasmine-core: 3.99.1
- jest: 29.7.0(@types/node@22.14.1)
+ jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3))
jest-environment-jsdom: 29.7.0
jest-jasmine2: 29.7.0
jest-junit: 13.2.0
@@ -13060,15 +13069,15 @@ snapshots:
lodash: 4.17.21
redent: 3.0.0
- '@testing-library/react@13.4.0(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@testing-library/react@16.3.0(@testing-library/dom@8.20.1)(@types/react-dom@18.3.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@testing-library/dom': 8.20.1
- '@types/react-dom': 18.3.7(@types/react@19.2.2)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- transitivePeerDependencies:
- - '@types/react'
+ optionalDependencies:
+ '@types/react': 19.2.2
+ '@types/react-dom': 18.3.7(@types/react@19.2.2)
'@testing-library/user-event@14.6.1(@testing-library/dom@8.20.1)':
dependencies:
@@ -17026,18 +17035,6 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0(@types/node@22.14.1):
- dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3))
- '@jest/types': 29.6.3
- import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3))
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - supports-color
- - ts-node
-
jest@29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3))
@@ -19904,7 +19901,7 @@ snapshots:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 29.7.0(@types/node@22.14.1)
+ jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6