diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
index f6f3a80d50..49cfaaa2aa 100644
--- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx
@@ -5,7 +5,7 @@ import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
import { ContainerProvider } from "brandi-react";
import { observer } from "mobx-react-lite";
-import { ReactElement, ReactNode, useCallback, useMemo } from "react";
+import { ReactElement, useCallback, useMemo } from "react";
import { DatagridContainerProps } from "../typings/DatagridProps";
import { Cell } from "./components/Cell";
import { Widget } from "./components/Widget";
@@ -84,11 +84,6 @@ const DatagridRoot = observer((props: DatagridContainerProps): ReactElement => {
columnsResizable={props.columnsResizable}
columnsSortable={props.columnsSortable}
data={items}
- emptyPlaceholderRenderer={useCallback(
- (renderWrapper: (children: ReactNode) => ReactElement) =>
- props.showEmptyPlaceholder === "custom" ? renderWrapper(props.emptyPlaceholder) :
,
- [props.emptyPlaceholder, props.showEmptyPlaceholder]
- )}
filterRenderer={useCallback(
(renderWrapper, columnIndex) => {
const columnFilter = columnsStore.columnFilters[columnIndex];
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
index 81a13fb18c..39604e653a 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx
@@ -1,7 +1,6 @@
import { RefreshIndicator } from "@mendix/widget-plugin-component-kit/RefreshIndicator";
import { Pagination } from "@mendix/widget-plugin-grid/components/Pagination";
import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController";
-import classNames from "classnames";
import { ListActionValue, ObjectItem } from "mendix";
import { observer } from "mobx-react-lite";
import { CSSProperties, Fragment, ReactElement, ReactNode } from "react";
@@ -12,6 +11,7 @@ import {
ShowPagingButtonsEnum
} from "../../typings/DatagridProps";
+import { EmptyPlaceholder } from "../features/empty-message/EmptyPlaceholder";
import { SelectAllBar } from "../features/select-all/SelectAllBar";
import { SelectionProgressDialog } from "../features/select-all/SelectionProgressDialog";
import { SelectActionHelper } from "../helpers/SelectActionHelper";
@@ -38,7 +38,6 @@ export interface WidgetProps ReactElement) => ReactElement;
exporting: boolean;
filterRenderer: (renderWrapper: (children: ReactNode) => ReactElement, columnIndex: number) => ReactElement;
hasMoreItems: boolean;
@@ -117,7 +116,6 @@ const Main = observer((props: WidgetProps): ReactElemen
CellComponent,
columnsHidable,
data: rows,
- emptyPlaceholderRenderer,
hasMoreItems,
headerContent,
headerTitle,
@@ -128,7 +126,6 @@ const Main = observer((props: WidgetProps): ReactElemen
paginationType,
paging,
pagingPosition,
- preview,
showRefreshIndicator,
selectActionHelper,
setPage,
@@ -216,23 +213,7 @@ const Main = observer((props: WidgetProps): ReactElemen
eventsController={props.cellEventsController}
pageSize={props.pageSize}
/>
- {(rows.length === 0 || preview) &&
- emptyPlaceholderRenderer &&
- emptyPlaceholderRenderer(children => (
-
- ))}
+
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx
index 8f9f717f6f..c0d379ab1e 100644
--- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx
+++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx
@@ -137,8 +137,7 @@ describe.skip("Table", () => {
it("renders the structure correctly with empty placeholder", () => {
const component = renderWithRootContext({
- ...mockWidgetProps(),
- emptyPlaceholderRenderer: renderWrapper => renderWrapper()
+ ...mockWidgetProps()
});
expect(component.asFragment()).toMatchSnapshot();
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/select-all/SelectAllBar.viewModel.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts
index 5f063b2f03..3f7c14f35a 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllBar.viewModel.ts
@@ -1,160 +1,54 @@
-import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
-import { DynamicValue, ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix";
-import { action, makeAutoObservable, reaction } from "mobx";
-
-type DynamicProps = {
- datasource: ListValue;
- selectAllTemplate?: DynamicValue;
- selectAllText?: DynamicValue;
- itemSelection?: SelectionSingleValue | SelectionMultiValue;
- allSelectedText?: DynamicValue;
-};
-
-interface SelectService {
- selectAllPages(): Promise<{ success: boolean }> | { success: boolean };
- clearSelection(): void;
-}
-
-interface CounterService {
- selectedCount: number;
- selectedCountText: string;
- clearButtonLabel: string;
-}
+import { SelectAllEvents } from "@mendix/widget-plugin-grid/select-all/select-all.model";
+import { Emitter } from "@mendix/widget-plugin-mobx-kit/main";
+import { makeAutoObservable } from "mobx";
/** @injectable */
-export class SelectAllBarViewModel implements SetupComponent {
- private barVisible = false;
- private clearVisible = false;
-
- pending = false;
-
+export class SelectAllBarViewModel {
constructor(
- host: SetupComponentHost,
- private readonly gate: DerivedPropsGate,
- private readonly selectService: SelectService,
- private readonly count: CounterService,
- private readonly enableSelectAll: boolean
+ private emitter: Emitter,
+ private state: { pending: boolean; visible: boolean; clearBtnVisible: boolean },
+ private selectionTexts: {
+ clearSelectionButtonLabel: string;
+ selectedCountText: string;
+ },
+ private selectAllTexts: {
+ selectAllLabel: string;
+ selectionStatus: string;
+ },
+ private enableSelectAll: boolean
) {
- host.add(this);
- type PrivateMembers = "setClearVisible" | "setPending" | "hideBar" | "showBar";
- makeAutoObservable(this, {
- setClearVisible: action,
- setPending: action,
- hideBar: action,
- showBar: action
- });
- }
-
- private get props(): DynamicProps {
- return this.gate.props;
- }
-
- private setClearVisible(value: boolean): void {
- this.clearVisible = value;
- }
-
- private setPending(value: boolean): void {
- this.pending = value;
- }
-
- private hideBar(): void {
- this.barVisible = false;
- this.clearVisible = false;
- }
-
- private showBar(): void {
- this.barVisible = true;
- }
-
- private get total(): number {
- return this.props.datasource.totalCount ?? 0;
- }
-
- private get selectAllFormat(): string {
- return this.props.selectAllTemplate?.value ?? "Select all %d rows in the data source";
- }
-
- private get selectAllText(): string {
- return this.props.selectAllText?.value ?? "Select all rows in the data source";
- }
-
- private get allSelectedText(): string {
- const str = this.props.allSelectedText?.value ?? "All %d rows selected.";
- return str.replace("%d", `${this.count.selectedCount}`);
- }
-
- private get isCurrentPageSelected(): boolean {
- const selection = this.props.itemSelection;
-
- if (!selection || selection.type === "Single") return false;
-
- const pageIds = new Set(this.props.datasource.items?.map(item => item.id) ?? []);
- const selectionSubArray = selection.selection.filter(item => pageIds.has(item.id));
- return selectionSubArray.length === pageIds.size && pageIds.size > 0;
- }
-
- private get isAllItemsSelected(): boolean {
- if (this.total > 0) return this.total === this.count.selectedCount;
-
- const { offset, limit, items = [], hasMoreItems } = this.gate.props.datasource;
- const noMoreItems = typeof hasMoreItems === "boolean" && hasMoreItems === false;
- const fullyLoaded = offset === 0 && limit >= items.length;
-
- return fullyLoaded && noMoreItems && items.length === this.count.selectedCount;
+ makeAutoObservable(this);
}
get selectAllLabel(): string {
- if (this.total > 0) return this.selectAllFormat.replace("%d", `${this.total}`);
- return this.selectAllText;
+ return this.selectAllTexts.selectAllLabel;
}
get clearSelectionLabel(): string {
- return this.count.clearButtonLabel;
+ return this.selectionTexts.clearSelectionButtonLabel;
}
get selectionStatus(): string {
- if (this.isAllItemsSelected) return this.allSelectedText;
- return this.count.selectedCountText;
+ return this.selectAllTexts.selectionStatus;
}
get isBarVisible(): boolean {
- return this.enableSelectAll && this.barVisible;
+ return this.enableSelectAll && this.state.visible;
}
get isClearVisible(): boolean {
- return this.clearVisible;
+ return this.state.clearBtnVisible;
}
get isSelectAllDisabled(): boolean {
- return this.pending;
- }
-
- setup(): (() => void) | void {
- if (!this.enableSelectAll) return;
-
- return reaction(
- () => this.isCurrentPageSelected,
- isCurrentPageSelected => {
- if (isCurrentPageSelected === false) {
- this.hideBar();
- } else if (this.isAllItemsSelected === false) {
- this.showBar();
- }
- }
- );
+ return this.state.pending;
}
onClear(): void {
- this.selectService.clearSelection();
+ this.emitter.emit("clear");
}
- async onSelectAll(): Promise {
- this.setPending(true);
- try {
- const { success } = await this.selectService.selectAllPages();
- this.setClearVisible(success);
- } finally {
- this.setPending(false);
- }
+ onSelectAll(): void {
+ this.emitter.emit("startSelecting");
}
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts
deleted file mode 100644
index 4476056573..0000000000
--- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllGateProps.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix";
-
-export type SelectAllGateProps = {
- datasource: ListValue;
- itemSelection?: SelectionSingleValue | SelectionMultiValue;
-};
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts
index d191741e31..1d86ba4f7c 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/SelectAllModule.container.ts
@@ -1,32 +1,105 @@
-import { DatasourceService, ProgressService, SelectAllService } from "@mendix/widget-plugin-grid/main";
+import { DatasourceService, SelectAllService, TaskProgressService } from "@mendix/widget-plugin-grid/main";
import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
-import { Container } from "brandi";
-import { TOKENS } from "../../model/tokens";
-import { SelectAllGateProps } from "./SelectAllGateProps";
+import { Container, injected } from "brandi";
+
+import { SelectAllFeature } from "@mendix/widget-plugin-grid/select-all/select-all.feature";
+import { selectAllEmitter, selectAllTextsStore } from "@mendix/widget-plugin-grid/select-all/select-all.model";
+import { SelectAllBarStore } from "@mendix/widget-plugin-grid/select-all/SelectAllBar.store";
+import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
+import { MainGateProps } from "../../../typings/MainGateProps";
+import { DatagridConfig } from "../../model/configs/Datagrid.config";
+import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../../model/tokens";
+import { SelectAllBarViewModel } from "./SelectAllBar.viewModel";
+import { SelectionProgressDialogViewModel } from "./SelectionProgressDialog.viewModel";
+
+injected(
+ selectAllTextsStore,
+ SA_TOKENS.gate,
+ CORE.selection.selectedCount,
+ CORE.selection.selectedCounterTextsStore,
+ CORE.atoms.totalCount,
+ CORE.selection.isAllItemsSelected
+);
+
+injected(
+ SelectAllBarViewModel,
+ SA_TOKENS.emitter,
+ SA_TOKENS.barStore,
+ CORE.selection.selectedCounterTextsStore,
+ SA_TOKENS.selectAllTextsStore,
+ SA_TOKENS.enableSelectAll
+);
+
+injected(
+ SelectionProgressDialogViewModel,
+ CORE.setupService,
+ SA_TOKENS.gate,
+ SA_TOKENS.progressService,
+ SA_TOKENS.selectAllService
+);
+
+injected(
+ SelectAllFeature,
+ CORE.setupService,
+ SA_TOKENS.emitter,
+ SA_TOKENS.selectAllService,
+ SA_TOKENS.barStore,
+ SA_TOKENS.progressService,
+ CORE.selection.isCurrentPageSelected,
+ CORE.selection.isAllItemsSelected
+);
+
+injected(SelectAllService, SA_TOKENS.gate, DG.query, SA_TOKENS.emitter);
export class SelectAllModule extends Container {
id = `SelectAllModule@${generateUUID()}`;
- init(props: SelectAllGateProps, root: Container): SelectAllModule {
+ constructor(root: Container) {
+ super();
this.extend(root);
+ this.bind(SA_TOKENS.barStore).toInstance(SelectAllBarStore).inSingletonScope();
+ this.bind(SA_TOKENS.emitter).toInstance(selectAllEmitter).inSingletonScope();
+ this.bind(DG.query).toInstance(DatasourceService).inSingletonScope();
+ this.bind(SA_TOKENS.selectAllService).toInstance(SelectAllService).inSingletonScope();
+ this.bind(SA_TOKENS.selectAllTextsStore).toInstance(selectAllTextsStore).inSingletonScope();
+ this.bind(SA_TOKENS.selectAllBarVM).toInstance(SelectAllBarViewModel).inSingletonScope();
+ this.bind(SA_TOKENS.selectionDialogVM).toInstance(SelectionProgressDialogViewModel).inSingletonScope();
+ this.bind(SA_TOKENS.feature).toInstance(SelectAllFeature).inSingletonScope();
+ }
+
+ init(dependencies: {
+ props: MainGateProps;
+ mainGate: DerivedPropsGate;
+ progressSrv: TaskProgressService;
+ config: DatagridConfig;
+ }): SelectAllModule {
+ const { props, config, mainGate, progressSrv } = dependencies;
- const gateProvider = new GateProvider(props);
- this.setProps = props => gateProvider.setProps(props);
+ const ownGate = new GateProvider(props);
+ this.setProps = props => ownGate.setProps(props);
- // Bind service deps
- this.bind(TOKENS.selectAllGate).toConstant(gateProvider.gate);
- this.bind(TOKENS.queryGate).toConstant(gateProvider.gate);
- this.bind(TOKENS.query).toInstance(DatasourceService).inSingletonScope();
- this.bind(TOKENS.selectAllProgressService).toInstance(ProgressService).inSingletonScope();
+ this.bind(CORE.config).toConstant(config);
+ // Bind main gate from main provider.
+ this.bind(CORE.mainGate).toConstant(mainGate);
+ this.bind(SA_TOKENS.progressService).toConstant(progressSrv);
+ this.bind(SA_TOKENS.gate).toConstant(ownGate.gate);
+ this.bind(DG.queryGate).toConstant(ownGate.gate);
+ this.bind(SA_TOKENS.enableSelectAll).toConstant(config.enableSelectAll);
- // Finally bind select all service
- this.bind(TOKENS.selectAllService).toInstance(SelectAllService).inSingletonScope();
+ this.postInit();
return this;
}
- setProps = (_props: SelectAllGateProps): void => {
+ postInit(): void {
+ // Initialize feature
+ if (this.get(SA_TOKENS.enableSelectAll)) {
+ this.get(SA_TOKENS.feature);
+ }
+ }
+
+ setProps = (_props: MainGateProps): void => {
throw new Error(`${this.id} is not initialized yet`);
};
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts
index fdb34406e8..8e46db7ed0 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/select-all/injection-hooks.ts
@@ -1,5 +1,5 @@
import { createInjectionHooks } from "brandi-react";
-import { TOKENS } from "../../model/tokens";
+import { SA_TOKENS } from "../../model/tokens";
-export const [useSelectAllBarViewModel] = createInjectionHooks(TOKENS.selectAllBarVM);
-export const [useSelectionDialogViewModel] = createInjectionHooks(TOKENS.selectionDialogVM);
+export const [useSelectAllBarViewModel] = createInjectionHooks(SA_TOKENS.selectAllBarVM);
+export const [useSelectionDialogViewModel] = createInjectionHooks(SA_TOKENS.selectionDialogVM);
diff --git a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts
index bfe4f153fc..519c6fe216 100644
--- a/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/features/selection-counter/injection-hooks.ts
@@ -1,4 +1,4 @@
import { createInjectionHooks } from "brandi-react";
-import { TOKENS } from "../../model/tokens";
+import { DG_TOKENS } from "../../model/tokens";
-export const [useSelectionCounterViewModel] = createInjectionHooks(TOKENS.selectionCounterVM);
+export const [useSelectionCounterViewModel] = createInjectionHooks(DG_TOKENS.selectionCounterVM);
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts b/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts
index 5a40e0d81b..0daaf02546 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/configs/Datagrid.config.ts
@@ -12,6 +12,7 @@ export interface DatagridConfig {
selectionEnabled: boolean;
selectorColumnEnabled: boolean;
settingsStorageEnabled: boolean;
+ enableSelectAll: boolean;
}
export function datagridConfig(props: DatagridContainerProps): DatagridConfig {
@@ -26,7 +27,8 @@ export function datagridConfig(props: DatagridContainerProps): DatagridConfig {
selectAllCheckboxEnabled: props.showSelectAllToggle,
selectionEnabled: isSelectionEnabled(props),
selectorColumnEnabled: props.columnsHidable,
- settingsStorageEnabled: isSettingsStorageEnabled(props)
+ settingsStorageEnabled: isSettingsStorageEnabled(props),
+ enableSelectAll: props.enableSelectAll
});
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
index df2664ce0a..1878ef2326 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Datagrid.container.ts
@@ -1,118 +1,124 @@
import { WidgetFilterAPI } from "@mendix/widget-plugin-filtering/context";
import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter";
import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost";
-import { 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 { DatasourceService, TaskProgressService } from "@mendix/widget-plugin-grid/main";
+import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms";
+import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/main";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
-import { Container } from "brandi";
-import { DatagridContainerProps } from "../../../typings/DatagridProps";
+import { Container, injected } from "brandi";
import { MainGateProps } from "../../../typings/MainGateProps";
-import { SelectAllBarViewModel } from "../../features/select-all/SelectAllBar.viewModel";
-import { SelectionProgressDialogViewModel } from "../../features/select-all/SelectionProgressDialog.viewModel";
+import { EmptyPlaceholderViewModel } from "../../features/empty-message/EmptyPlaceholder.viewModel";
+import { SelectAllModule } from "../../features/select-all/SelectAllModule.container";
import { ColumnGroupStore } from "../../helpers/state/ColumnGroupStore";
import { GridBasicData } from "../../helpers/state/GridBasicData";
import { GridPersonalizationStore } from "../../helpers/state/GridPersonalizationStore";
-import { DatagridConfig, datagridConfig } from "../configs/Datagrid.config";
+import { DatagridConfig } from "../configs/Datagrid.config";
import { DatasourceParamsController } from "../services/DatasourceParamsController";
import { DerivedLoaderController } from "../services/DerivedLoaderController";
import { PaginationController } from "../services/PaginationController";
-import { TOKENS } from "../tokens";
+import { CORE_TOKENS as CORE, DG_TOKENS as DG, SA_TOKENS } from "../tokens";
+
+injected(ColumnGroupStore, CORE.setupService, CORE.mainGate, CORE.config, DG.filterHost);
+injected(CombinedFilter, CORE.setupService, DG.combinedFilterConfig);
+injected(DatasourceParamsController, CORE.setupService, DG.query, DG.combinedFilter, CORE.columnsStore);
+injected(DatasourceService, CORE.setupService, DG.queryGate, DG.refreshInterval.optional);
+injected(DerivedLoaderController, DG.query, DG.exportProgressService, CORE.columnsStore, DG.loaderConfig);
+injected(EmptyPlaceholderViewModel, DG.emptyPlaceholderWidgets, CORE.atoms.itemCount, CORE.config);
+injected(GridBasicData, CORE.mainGate);
+injected(GridPersonalizationStore, CORE.setupService, CORE.mainGate, CORE.columnsStore, DG.filterHost);
+injected(PaginationController, CORE.setupService, DG.paginationConfig, DG.query);
+injected(WidgetFilterAPI, DG.parentChannelName, DG.filterHost);
+injected(emptyStateWidgetsAtom, CORE.mainGate, CORE.atoms.itemCount);
+
+injected(
+ SelectionCounterViewModel,
+ CORE.selection.selectedCount,
+ CORE.selection.selectedCounterTextsStore,
+ DG.selectionCounterCfg.optional
+);
export class DatagridContainer extends Container {
id = `DatagridContainer@${generateUUID()}`;
- /**
- * Setup container bindings.
- * @remark Make sure not to bind things that already exist in root container.
- */
- init(props: DatagridContainerProps, root: Container, selectAllModule: Container): DatagridContainer {
+ constructor(root: Container) {
+ super();
this.extend(root);
- // Connect select all module
- const selectAllService = selectAllModule.get(TOKENS.selectAllService);
- const selectAllProgress = selectAllModule.get(TOKENS.selectAllProgressService);
- // Bind select all service
- this.bind(TOKENS.selectAllService).toConstant(selectAllService);
- // Bind select all progress
- this.bind(TOKENS.selectAllProgressService).toConstant(selectAllProgress);
-
- // Create main gate
- this.bind(TOKENS.exportProgressService).toInstance(ProgressService).inSingletonScope();
- const exportProgress = this.get(TOKENS.exportProgressService);
- const gateProvider = new ClosableGateProvider(props, function isLocked() {
- return exportProgress.inProgress || selectAllProgress.inProgress;
- });
- this.setProps = props => gateProvider.setProps(props);
-
- // Bind main gate
- this.bind(TOKENS.mainGate).toConstant(gateProvider.gate);
- this.bind(TOKENS.queryGate).toConstant(gateProvider.gate);
-
- // Bind config
- const config = datagridConfig(props);
- this.bind(TOKENS.config).toConstant(config);
-
- // Columns store
- this.bind(TOKENS.columnsStore).toInstance(ColumnGroupStore).inSingletonScope();
-
// Basic data store
- this.bind(TOKENS.basicDate).toInstance(GridBasicData).inSingletonScope();
-
- // Combined filter
- this.bind(TOKENS.combinedFilter).toInstance(CombinedFilter).inSingletonScope();
-
- // Export progress
- this.bind(TOKENS.exportProgressService).toInstance(ProgressService).inSingletonScope();
-
+ this.bind(DG.basicDate).toInstance(GridBasicData).inSingletonScope();
+ // Columns store
+ this.bind(CORE.columnsStore).toInstance(ColumnGroupStore).inSingletonScope();
+ // Query service
+ this.bind(DG.query).toInstance(DatasourceService).inSingletonScope();
+ // Pagination service
+ this.bind(DG.paginationService).toInstance(PaginationController).inSingletonScope();
+ // Datasource params service
+ this.bind(DG.paramsService).toInstance(DatasourceParamsController).inSingletonScope();
// FilterAPI
- this.bind(TOKENS.filterAPI).toInstance(WidgetFilterAPI).inSingletonScope();
-
+ this.bind(DG.filterAPI).toInstance(WidgetFilterAPI).inSingletonScope();
// Filter host
- this.bind(TOKENS.filterHost).toInstance(CustomFilterHost).inSingletonScope();
-
- // Datasource params service
- this.bind(TOKENS.paramsService).toInstance(DatasourceParamsController).inSingletonScope();
-
+ this.bind(DG.filterHost).toInstance(CustomFilterHost).inSingletonScope();
+ // Combined filter
+ this.bind(DG.combinedFilter).toInstance(CombinedFilter).inSingletonScope();
// Personalization service
- this.bind(TOKENS.personalizationService).toInstance(GridPersonalizationStore).inSingletonScope();
+ this.bind(DG.personalizationService).toInstance(GridPersonalizationStore).inSingletonScope();
+ // Loader view model
+ this.bind(DG.loaderVM).toInstance(DerivedLoaderController).inSingletonScope();
+ // Selection counter view model
+ this.bind(DG.selectionCounterVM).toInstance(SelectionCounterViewModel).inSingletonScope();
+ // Empty placeholder
+ this.bind(DG.emptyPlaceholderVM).toInstance(EmptyPlaceholderViewModel).inSingletonScope();
+ this.bind(DG.emptyPlaceholderWidgets).toInstance(emptyStateWidgetsAtom).inTransientScope();
+ }
- // Query service
- this.bind(TOKENS.query).toInstance(DatasourceService).inSingletonScope();
+ /**
+ * Setup container constants. If possible, declare all other bindings in the constructor.
+ * @remark Make sure not to bind things that already exist in root container.
+ */
+ init(dependencies: {
+ props: MainGateProps;
+ config: DatagridConfig;
+ mainGate: DerivedPropsGate;
+ exportProgressService: TaskProgressService;
+ selectAllModule: SelectAllModule;
+ }): DatagridContainer {
+ const { props, config, mainGate, exportProgressService, selectAllModule } = dependencies;
- // Pagination service
- this.bind(TOKENS.paginationService).toInstance(PaginationController).inSingletonScope();
+ // Main gate
- // Events channel for child widgets
- this.bind(TOKENS.parentChannelName).toConstant(config.filtersChannelName);
+ this.bind(CORE.mainGate).toConstant(mainGate);
+ this.bind(DG.queryGate).toConstant(mainGate);
- // Loader view model
- this.bind(TOKENS.loaderVM).toInstance(DerivedLoaderController).inSingletonScope();
+ // Export progress service
+ this.bind(DG.exportProgressService).toConstant(exportProgressService);
- // Selection counter view model
- this.bind(TOKENS.selectionCounterVM).toInstance(SelectionCounterViewModel).inSingletonScope();
+ // Config
+ this.bind(CORE.config).toConstant(config);
- // Select all bar view model
- this.bind(TOKENS.selectAllBarVM).toInstance(SelectAllBarViewModel).inSingletonScope();
+ // Connect select all module
+ this.bind(SA_TOKENS.selectionDialogVM).toConstant(selectAllModule.get(SA_TOKENS.selectionDialogVM));
+ this.bind(SA_TOKENS.selectAllBarVM).toConstant(selectAllModule.get(SA_TOKENS.selectAllBarVM));
- // Selection progress dialog view model
- this.bind(TOKENS.selectionDialogVM).toInstance(SelectionProgressDialogViewModel).inSingletonScope();
+ // Events channel for child widgets
+ this.bind(DG.parentChannelName).toConstant(config.filtersChannelName);
// Bind refresh interval
- this.bind(TOKENS.refreshInterval).toConstant(props.refreshInterval * 1000);
+ this.bind(DG.refreshInterval).toConstant(config.refreshIntervalMs);
// Bind combined filter config
- this.bind(TOKENS.combinedFilterConfig).toConstant({
+ this.bind(DG.combinedFilterConfig).toConstant({
stableKey: props.name,
- inputs: [this.get(TOKENS.filterHost), this.get(TOKENS.columnsStore)]
+ inputs: [this.get(DG.filterHost), this.get(CORE.columnsStore)]
});
// Bind loader config
- this.bind(TOKENS.loaderConfig).toConstant({
+ this.bind(DG.loaderConfig).toConstant({
showSilentRefresh: props.refreshInterval > 1,
refreshIndicator: props.refreshIndicator
});
// Bind pagination config
- this.bind(TOKENS.paginationConfig).toConstant({
+ this.bind(DG.paginationConfig).toConstant({
pagination: props.pagination,
showPagingButtons: props.showPagingButtons,
showNumberOfRows: props.showNumberOfRows,
@@ -120,10 +126,7 @@ export class DatagridContainer extends Container {
});
// Bind selection counter position
- this.bind(TOKENS.selectionCounterPosition).toConstant(props.selectionCounterPosition);
-
- // Bind select all enabled flag
- this.bind(TOKENS.enableSelectAll).toConstant(props.enableSelectAll);
+ this.bind(DG.selectionCounterCfg).toConstant({ position: props.selectionCounterPosition });
this.postInit(props, config);
@@ -131,20 +134,16 @@ export class DatagridContainer extends Container {
}
/** Post init hook for final configuration. */
- private postInit(props: DatagridContainerProps, config: DatagridConfig): void {
+ private postInit(props: MainGateProps, config: DatagridConfig): void {
// Make sure essential services are created upfront
- this.get(TOKENS.paramsService);
- this.get(TOKENS.paginationService);
+ this.get(DG.paramsService);
+ this.get(DG.paginationService);
if (config.settingsStorageEnabled) {
- this.get(TOKENS.personalizationService);
+ this.get(DG.personalizationService);
}
// Hydrate filters from props
- this.get(TOKENS.combinedFilter).hydrate(props.datasource.filter);
+ this.get(DG.combinedFilter).hydrate(props.datasource.filter);
}
-
- setProps = (_props: MainGateProps): void => {
- throw new Error(`${this.id} is not initialized yet`);
- };
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts
index f4c9e2ea37..277cf65a7c 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/containers/Root.container.ts
@@ -1,18 +1,70 @@
+import {
+ hasMoreItemsAtom,
+ isAllItemsPresentAtom,
+ itemCountAtom,
+ limitAtom,
+ offsetAtom,
+ totalCountAtom
+} from "@mendix/widget-plugin-grid/core/models/datasource.model";
+import {
+ isAllItemsSelectedAtom,
+ isCurrentPageSelectedAtom,
+ selectedCountMultiAtom,
+ selectionCounterTextsStore
+} from "@mendix/widget-plugin-grid/core/models/selection.model";
import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid";
-import { Container } from "brandi";
+import { Container, injected } from "brandi";
+import { visibleColumnsCountAtom } from "../models/columns.model";
import { DatagridSetupService } from "../services/DatagridSetup.service";
-import { TOKENS } from "../tokens";
+import { CORE_TOKENS as CORE } from "../tokens";
+
+// datasource
+injected(totalCountAtom, CORE.mainGate);
+injected(itemCountAtom, CORE.mainGate);
+injected(offsetAtom, CORE.mainGate);
+injected(limitAtom, CORE.mainGate);
+injected(hasMoreItemsAtom, CORE.mainGate);
+injected(visibleColumnsCountAtom, CORE.columnsStore);
+injected(isAllItemsPresentAtom, CORE.atoms.offset, CORE.atoms.hasMoreItems);
+
+// selection
+injected(
+ isAllItemsSelectedAtom,
+ CORE.selection.selectedCount,
+ CORE.atoms.itemCount,
+ CORE.atoms.totalCount,
+ CORE.atoms.isAllItemsPresent
+);
+injected(isCurrentPageSelectedAtom, CORE.mainGate);
+injected(selectedCountMultiAtom, CORE.mainGate);
+injected(selectionCounterTextsStore, CORE.mainGate, CORE.selection.selectedCount);
/**
* Root container for bindings that can be shared down the hierarchy.
- * Use only for bindings that needs to be shared across multiple containers.
- * @remark Don't bind things that depend on props here.
+ * Declare only bindings that needs to be shared across multiple containers.
+ * @remark Don't bind constants or 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.visibleColumnsCount).toInstance(visibleColumnsCountAtom).inTransientScope();
+ this.bind(CORE.atoms.isAllItemsPresent).toInstance(isAllItemsPresentAtom).inTransientScope();
+
+ // selection
+ this.bind(CORE.selection.selectedCount).toInstance(selectedCountMultiAtom).inTransientScope();
+ this.bind(CORE.selection.isCurrentPageSelected).toInstance(isCurrentPageSelectedAtom).inTransientScope();
+ this.bind(CORE.selection.selectedCounterTextsStore).toInstance(selectionCounterTextsStore).inTransientScope();
+ this.bind(CORE.selection.isAllItemsSelected).toInstance(isAllItemsSelectedAtom).inTransientScope();
}
}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts
index 9b8a0d3efa..b23babaf98 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/injection-hooks.ts
@@ -1,11 +1,11 @@
import { createInjectionHooks } from "brandi-react";
-import { TOKENS } from "../tokens";
+import { CORE_TOKENS as CORE, DG_TOKENS as DG } from "../tokens";
-export const [useBasicData] = createInjectionHooks(TOKENS.basicDate);
-export const [useColumnsStore] = createInjectionHooks(TOKENS.columnsStore);
-export const [useDatagridConfig] = createInjectionHooks(TOKENS.config);
-export const [useDatagridFilterAPI] = createInjectionHooks(TOKENS.filterAPI);
-export const [useExportProgressService] = createInjectionHooks(TOKENS.exportProgressService);
-export const [useLoaderViewModel] = createInjectionHooks(TOKENS.loaderVM);
-export const [useMainGate] = createInjectionHooks(TOKENS.mainGate);
-export const [usePaginationService] = createInjectionHooks(TOKENS.paginationService);
+export const [useBasicData] = createInjectionHooks(DG.basicDate);
+export const [useColumnsStore] = createInjectionHooks(CORE.columnsStore);
+export const [useDatagridConfig] = createInjectionHooks(CORE.config);
+export const [useDatagridFilterAPI] = createInjectionHooks(DG.filterAPI);
+export const [useExportProgressService] = createInjectionHooks(DG.exportProgressService);
+export const [useLoaderViewModel] = createInjectionHooks(DG.loaderVM);
+export const [useMainGate] = createInjectionHooks(CORE.mainGate);
+export const [usePaginationService] = createInjectionHooks(DG.paginationService);
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts
index d82c8bf1fe..cdc6f073f4 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/hooks/useDatagridContainer.ts
@@ -3,26 +3,46 @@ import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup";
import { Container } from "brandi";
import { useEffect } from "react";
import { DatagridContainerProps } from "../../../typings/DatagridProps";
+import { MainGateProps } from "../../../typings/MainGateProps";
import { SelectAllModule } from "../../features/select-all/SelectAllModule.container";
+import { datagridConfig } from "../configs/Datagrid.config";
import { DatagridContainer } from "../containers/Datagrid.container";
import { RootContainer } from "../containers/Root.container";
-import { TOKENS } from "../tokens";
+import { MainGateProvider } from "../services/MainGateProvider.service";
+import { CORE_TOKENS as CORE } from "../tokens";
export function useDatagridContainer(props: DatagridContainerProps): Container {
- const [container, selectAllModule] = useConst(function init(): [DatagridContainer, SelectAllModule] {
+ const [container, selectAllModule, mainProvider] = useConst(function init(): [
+ DatagridContainer,
+ SelectAllModule,
+ MainGateProvider
+ ] {
const root = new RootContainer();
- const selectAllModule = new SelectAllModule().init(props, root);
- const container = new DatagridContainer().init(props, root, selectAllModule);
+ const config = datagridConfig(props);
+ const mainProvider = new MainGateProvider(props);
+ const selectAllModule = new SelectAllModule(root).init({
+ props,
+ config,
+ mainGate: mainProvider.gate,
+ progressSrv: mainProvider.selectAllProgress
+ });
+ const container = new DatagridContainer(root).init({
+ props,
+ config,
+ selectAllModule,
+ mainGate: mainProvider.gate,
+ exportProgressService: mainProvider.exportProgress
+ });
- return [container, selectAllModule];
+ return [container, selectAllModule, mainProvider];
});
// Run setup hooks on mount
- useSetup(() => container.get(TOKENS.setupService));
+ useSetup(() => container.get(CORE.setupService));
// Push props through the gates
useEffect(() => {
- container.setProps(props);
+ mainProvider.setProps(props);
selectAllModule.setProps(props);
});
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts b/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts
new file mode 100644
index 0000000000..f3ba7dd46b
--- /dev/null
+++ b/packages/pluggableWidgets/datagrid-web/src/model/models/columns.model.ts
@@ -0,0 +1,7 @@
+import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main";
+import { computed } from "mobx";
+
+/** @injectable */
+export function visibleColumnsCountAtom(source: { visibleColumns: { length: number } }): ComputedAtom {
+ return computed(() => source.visibleColumns.length);
+}
diff --git a/packages/pluggableWidgets/datagrid-web/src/model/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/tokens.ts b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
index 76b7510f47..8b4ecf3e3a 100644
--- a/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
+++ b/packages/pluggableWidgets/datagrid-web/src/model/tokens.ts
@@ -1,98 +1,107 @@
-import { FilterAPI, WidgetFilterAPI } from "@mendix/widget-plugin-filtering/context";
+import { FilterAPI } from "@mendix/widget-plugin-filtering/context";
import { CombinedFilter, CombinedFilterConfig } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter";
import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost";
+import { QueryService, SelectAllService, TaskProgressService } from "@mendix/widget-plugin-grid/main";
+import { SelectAllFeature } from "@mendix/widget-plugin-grid/select-all/select-all.feature";
import {
- DatasourceService,
- QueryService,
- SelectAllService,
- SelectionCounterViewModel,
- TaskProgressService
-} from "@mendix/widget-plugin-grid/main";
-import { DerivedPropsGate, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main";
-import { injected, token } from "brandi";
+ BarStore,
+ ObservableSelectAllTexts,
+ SelectAllEvents
+} from "@mendix/widget-plugin-grid/select-all/select-all.model";
+import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/selection-counter/SelectionCounter.viewModel-atoms";
+import { ComputedAtom, DerivedPropsGate, Emitter } from "@mendix/widget-plugin-mobx-kit/main";
+import { token } from "brandi";
import { ListValue } from "mendix";
-import { SelectionCounterPositionEnum } from "../../typings/DatagridProps";
+import { ReactNode } from "react";
import { MainGateProps } from "../../typings/MainGateProps";
+import { EmptyPlaceholderViewModel } from "../features/empty-message/EmptyPlaceholder.viewModel";
import { SelectAllBarViewModel } from "../features/select-all/SelectAllBar.viewModel";
-import { SelectAllGateProps } from "../features/select-all/SelectAllGateProps";
import { SelectionProgressDialogViewModel } from "../features/select-all/SelectionProgressDialog.viewModel";
import { ColumnGroupStore } from "../helpers/state/ColumnGroupStore";
import { GridBasicData } from "../helpers/state/GridBasicData";
import { GridPersonalizationStore } from "../helpers/state/GridPersonalizationStore";
import { DatasourceParamsController } from "../model/services/DatasourceParamsController";
import { DatagridConfig } from "./configs/Datagrid.config";
+import { DatagridSetupService } from "./services/DatagridSetup.service";
import { DerivedLoaderController, DerivedLoaderControllerConfig } from "./services/DerivedLoaderController";
import { PaginationConfig, PaginationController } from "./services/PaginationController";
-/** Tokens to resolve dependencies from the container. Please keep in alphabetical order. */
-export const TOKENS = {
- basicDate: token("GridBasicData"),
+/** Tokens to resolve dependencies from the container. */
+
+/** Core tokens shared across containers through root container. */
+export const CORE_TOKENS = {
+ atoms: {
+ hasMoreItems: token>("@computed:hasMoreItems"),
+ itemCount: token>("@computed:itemCount"),
+ limit: token>("@computed:limit"),
+ offset: token>("@computed:offset"),
+ totalCount: token>("@computed:totalCount"),
+ visibleColumnsCount: token>("@computed:visibleColumnsCount"),
+ isAllItemsPresent: token>("@computed:isAllItemsPresent")
+ },
columnsStore: token("ColumnGroupStore"),
+
+ config: token("DatagridConfig"),
+
+ mainGate: token>("MainGate"),
+
+ selection: {
+ selectedCount: token>("@computed:selectedCount"),
+ isAllItemsSelected: token>("@computed:isAllItemsSelected"),
+ isCurrentPageSelected: token>("@computed:isCurrentPageSelected"),
+ selectedCounterTextsStore: token<{
+ clearSelectionButtonLabel: string;
+ selectedCountText: string;
+ }>("@store:selectedCounterTextsStore")
+ },
+
+ setupService: token("DatagridSetupService")
+};
+
+/** Datagrid tokens. */
+export const DG_TOKENS = {
+ basicDate: token("GridBasicData"),
+
combinedFilter: token("CombinedFilter"),
combinedFilterConfig: token("CombinedFilterKey"),
- config: token("DatagridConfig"),
- 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"),
+ refreshInterval: token("refreshInterval"),
+
+ paramsService: token("DatagridParamsService"),
personalizationService: token("GridPersonalizationStore"),
+
query: token("QueryService"),
queryGate: token>("GateForQueryService"),
- refreshInterval: token("refreshInterval"),
+
+ selectionCounterCfg: token<{ position: "top" | "bottom" | "off" }>("SelectionCounterConfig"),
+ selectionCounterVM: token("SelectionCounterViewModel")
+};
+
+/** "Select all" module tokens. */
+export const SA_TOKENS = {
+ barStore: token("SelectAllBarStore"),
+ emitter: token>("SelectAllEmitter"),
+ gate: token>("MainGateForSelectAllContainer"),
+ progressService: token("SelectAllProgressService"),
+ selectAllTextsStore: token("SelectAllTextsStore"),
selectAllBarVM: token("SelectAllBarViewModel"),
- selectAllGate: token>("GateForSelectAllService"),
- selectAllProgressService: token("SelectAllProgressService"),
selectAllService: token("SelectAllService"),
- selectionCounterPosition: token("SelectionCounterPositionEnum"),
- selectionCounterVM: token("SelectionCounterViewModel"),
selectionDialogVM: token("SelectionProgressDialogViewModel"),
- setupService: token("DatagridSetupHost")
+ enableSelectAll: token("enableSelectAllFeatureFlag"),
+ feature: token("SelectAllFeature")
};
-
-/** Inject dependencies */
-
-injected(ColumnGroupStore, TOKENS.setupService, TOKENS.mainGate, TOKENS.config, TOKENS.filterHost);
-
-injected(GridBasicData, TOKENS.mainGate);
-
-injected(CombinedFilter, TOKENS.setupService, TOKENS.combinedFilterConfig);
-
-injected(WidgetFilterAPI, TOKENS.parentChannelName, TOKENS.filterHost);
-
-injected(DatasourceParamsController, TOKENS.setupService, TOKENS.query, TOKENS.combinedFilter, TOKENS.columnsStore);
-
-injected(GridPersonalizationStore, TOKENS.setupService, TOKENS.mainGate, TOKENS.columnsStore, TOKENS.filterHost);
-
-injected(PaginationController, TOKENS.setupService, TOKENS.paginationConfig, TOKENS.query);
-
-injected(DatasourceService, TOKENS.setupService, TOKENS.queryGate, TOKENS.refreshInterval.optional);
-
-injected(DerivedLoaderController, TOKENS.query, TOKENS.exportProgressService, TOKENS.columnsStore, TOKENS.loaderConfig);
-
-injected(SelectionCounterViewModel, TOKENS.mainGate, TOKENS.selectionCounterPosition);
-
-injected(SelectAllService, TOKENS.setupService, TOKENS.selectAllGate, TOKENS.query, TOKENS.selectAllProgressService);
-
-injected(
- SelectAllBarViewModel,
- TOKENS.setupService,
- TOKENS.mainGate,
- TOKENS.selectAllService,
- TOKENS.selectionCounterVM,
- TOKENS.enableSelectAll
-);
-
-injected(
- SelectionProgressDialogViewModel,
- TOKENS.setupService,
- TOKENS.mainGate,
- TOKENS.selectAllProgressService,
- TOKENS.selectAllService
-);
diff --git a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
index 6567fd1986..de4af19873 100644
--- a/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
+++ b/packages/pluggableWidgets/datagrid-web/typings/MainGateProps.ts
@@ -3,27 +3,26 @@ import { DatagridContainerProps } from "./DatagridProps";
/** Type to declare props available through main gate. */
export type MainGateProps = Pick<
DatagridContainerProps,
- | "name"
- | "datasource"
- | "refreshInterval"
- | "refreshIndicator"
- | "itemSelection"
+ | "allSelectedText"
+ | "cancelSelectionLabel"
+ | "clearSelectionButtonLabel"
| "columns"
- | "configurationStorageType"
- | "storeFiltersInPersonalization"
| "configurationAttribute"
+ | "configurationStorageType"
+ | "datasource"
+ | "emptyPlaceholder"
+ | "enableSelectAll"
+ | "itemSelection"
+ | "name"
| "pageSize"
| "pagination"
- | "showPagingButtons"
- | "showNumberOfRows"
- | "clearSelectionButtonLabel"
+ | "refreshIndicator"
+ | "refreshInterval"
+ | "selectAllRowsLabel"
| "selectAllTemplate"
| "selectAllText"
- | "itemSelection"
- | "datasource"
- | "allSelectedText"
- | "selectAllRowsLabel"
- | "cancelSelectionLabel"
| "selectionCounterPosition"
- | "enableSelectAll"
+ | "showNumberOfRows"
+ | "showPagingButtons"
+ | "storeFiltersInPersonalization"
>;
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/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/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" });