diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index e311d2e0d9..ed8c75235c 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -429,6 +429,86 @@ $root: ".widget-datagrid"; display: contents; } + &-refresh-container { + grid-column: 1 / -1; + padding: 0; + position: relative; + } + + &-refresh-indicator { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: var(--border-color-default, #ced0d3); + border: none; + border-radius: 2px; + color: var(--brand-primary, $dg-brand-primary); + height: 4px; + width: 100%; + position: absolute; + left: 0; + right: 0; + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-value { + background-color: currentColor; + transition: all 0.2s; + } + + &::-moz-progress-bar { + background-color: currentColor; + transition: all 0.2s; + } + + &::-ms-fill { + border: none; + background-color: currentColor; + transition: all 0.2s; + } + + &:indeterminate { + background-size: 200% 100%; + background-image: linear-gradient( + to right, + transparent 50%, + currentColor 50%, + currentColor 60%, + transparent 60%, + transparent 71.5%, + currentColor 71.5%, + currentColor 84%, + transparent 84% + ); + animation: progress-linear 2s infinite linear; + } + + &:indeterminate::-moz-progress-bar { + background-color: transparent; + } + + &:indeterminate::-ms-fill { + animation-name: none; + } + + @keyframes progress-linear { + 0% { + background-size: 200% 100%; + background-position: left -31.25% top 0%; + } + 50% { + background-size: 800% 100%; + background-position: left -49% top 0%; + } + 100% { + background-size: 400% 100%; + background-position: left -102% top 0%; + } + } + } + &.widget-datagrid-selection-method-click { .tr.tr-selected .td { background-color: $dg-grid-selected-row-background; diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 359240dd41..ad66adaba6 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- We implemented a new property to show a refresh indicator. With the refresh indicator, any datasource change shows a progress bar on top of Datagrid 2. + ## [2.30.6] - 2025-05-28 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index ba1df3f098..31c1184c3d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -141,10 +141,12 @@ export function preview(props: DatagridPreviewProps): ReactElement { cellEventsController={eventsController} checkboxEventsController={eventsController} focusController={focusController} + isFirstLoad={false} isLoading={false} isFetchingNextBatch={false} loadingType="spinner" columnsLoading={false} + refreshIndicator={props.refreshIndicator} /> ); } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 78abad3963..1a18f8c186 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -118,10 +118,12 @@ const Container = observer((props: Props): ReactElement => { cellEventsController={cellEventsController} checkboxEventsController={checkboxEventsController} focusController={focusController} - isLoading={rootStore.loaderCtrl.isLoading} + isFirstLoad={rootStore.loaderCtrl.isFirstLoad} isFetchingNextBatch={rootStore.loaderCtrl.isFetchingNextBatch} + isLoading={props.datasource.status === "loading"} loadingType={props.loadingType} columnsLoading={!columnsStore.loaded} + refreshIndicator={props.refreshIndicator} /> ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index 8782abf1a6..98b1ccd98f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -57,6 +57,10 @@ Skeleton + + Show refresh indicator + Show a refresh indicator when the data is being loaded. + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx index dc3bac60c7..64c1ca93b8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/GridBody.tsx @@ -8,7 +8,7 @@ interface Props { className?: string; children?: React.ReactNode; loadingType: LoadingTypeEnum; - isLoading: boolean; + isFirstLoad: boolean; isFetchingNextBatch?: boolean; columnsHidable: boolean; columnsSize: number; @@ -20,7 +20,7 @@ export function GridBody(props: Props): ReactElement { const { children } = props; const content = (): React.ReactElement => { - if (props.isLoading) { + if (props.isFirstLoad) { return 0 ? props.rowsSize : props.pageSize} />; } return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/RefreshIndicator.tsx b/packages/pluggableWidgets/datagrid-web/src/components/RefreshIndicator.tsx new file mode 100644 index 0000000000..79f3f3a10f --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/RefreshIndicator.tsx @@ -0,0 +1,11 @@ +import { createElement, ReactElement } from "react"; + +export function RefreshIndicator(): ReactElement { + return ( +
+
+ +
+
+ ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index c3d1beeba2..048f5b704c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -25,6 +25,7 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navig import { observer } from "mobx-react-lite"; import { RowsRenderer } from "./RowsRenderer"; import { GridHeader } from "./GridHeader"; +import { RefreshIndicator } from "./RefreshIndicator"; export interface WidgetProps { CellComponent: CellComponent; @@ -65,10 +66,12 @@ export interface WidgetProps(props: WidgetProps): ReactElemen paging, pagingPosition, preview, + refreshIndicator, selectActionHelper, setPage, visibleColumns @@ -161,6 +165,8 @@ const Main = observer((props: WidgetProps): ReactElemen const selectionEnabled = selectActionHelper.selectionType !== "None"; + const showRefreshIndicator = refreshIndicator && props.isLoading && !props.isFirstLoad; + return ( {showTopBar && {pagination}} @@ -189,8 +195,9 @@ const Main = observer((props: WidgetProps): ReactElemen isLoading={props.columnsLoading} preview={props.preview} /> + {showRefreshIndicator ? : null} { selectActionHelper: mockSelectionProps(), cellEventsController: { getProps: () => Object.create({}) }, checkboxEventsController: { getProps: () => Object.create({}) }, + isFirstLoad: false, isLoading: false, isFetchingNextBatch: false, loadingType: "spinner", columnsLoading: false, + refreshIndicator: false, focusController: new FocusTargetController( new PositionController(), new VirtualGridLayout(1, columns.length, 10) diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index edbce23cfd..052bb22244 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -96,6 +96,7 @@ export interface DatagridContainerProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; loadingType: LoadingTypeEnum; + refreshIndicator: boolean; columns: ColumnsType[]; columnsFilterable: boolean; pageSize: number; @@ -144,6 +145,7 @@ export interface DatagridPreviewProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; loadingType: LoadingTypeEnum; + refreshIndicator: boolean; columns: ColumnsPreviewType[]; columnsFilterable: boolean; pageSize: number | null; diff --git a/packages/shared/widget-plugin-grid/src/__tests__/DatasourceController.spec.ts b/packages/shared/widget-plugin-grid/src/__tests__/DatasourceController.spec.ts index c2cbe59476..fc5cd93dea 100644 --- a/packages/shared/widget-plugin-grid/src/__tests__/DatasourceController.spec.ts +++ b/packages/shared/widget-plugin-grid/src/__tests__/DatasourceController.spec.ts @@ -21,8 +21,8 @@ describe("DatasourceController loading states", () => { provider.setProps({ datasource }); }); - it("isLoading returns true by default", () => { - expect(controller.isLoading).toBe(true); + it("isFirstLoad returns true by default", () => { + expect(controller.isFirstLoad).toBe(true); }); it("refresh has no effect if ds is loading", () => { @@ -39,13 +39,13 @@ describe("DatasourceController loading states", () => { provider.setProps({ datasource: list.loading() }); expect(provider.gate.props.datasource.status).toBe("loading"); expect(controller.isRefreshing).toBe(true); - expect(controller.isLoading).toBe(false); + expect(controller.isFirstLoad).toBe(true); }); it("isFetchingNextBatch returns true after setLimit call", () => { controller.setLimit(20); expect(controller.isFetchingNextBatch).toBe(true); - expect(controller.isLoading).toBe(false); + expect(controller.isFirstLoad).toBe(true); }); }); @@ -56,7 +56,7 @@ describe("DatasourceController loading states", () => { }); it("all loading states return false", () => { - expect(controller.isLoading).toBe(false); + expect(controller.isFirstLoad).toBe(true); expect(controller.isRefreshing).toBe(false); expect(controller.isFetchingNextBatch).toBe(false); }); diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index 5f5c5b0b1f..0804365bd2 100644 --- a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts +++ b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts @@ -1,7 +1,7 @@ import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; import { ListValue, ValueStatus } from "mendix"; -import { action, autorun, computed, IComputedValue, makeAutoObservable } from "mobx"; +import { action, autorun, computed, IComputedValue, makeAutoObservable, when } from "mobx"; import { QueryController } from "./query-controller"; type Gate = DerivedPropsGate<{ datasource: ListValue }>; @@ -13,19 +13,27 @@ export class DatasourceController implements ReactiveController, QueryController private refreshing = false; private fetching = false; private pageSize = Infinity; + private isLoaded = false; constructor(host: ReactiveControllerHost, spec: DatasourceControllerSpec) { host.addController(this); this.gate = spec.gate; - type PrivateMembers = "resetFlags" | "updateFlags" | "setRefreshing" | "setFetching" | "pageSize"; + type PrivateMembers = + | "resetFlags" + | "updateFlags" + | "setRefreshing" + | "setFetching" + | "pageSize" + | "setIsLoaded"; makeAutoObservable(this, { setup: false, pageSize: false, updateFlags: action, resetFlags: action, setRefreshing: action, - setFetching: action + setFetching: action, + setIsLoaded: action }); } @@ -51,6 +59,10 @@ export class DatasourceController implements ReactiveController, QueryController this.fetching = value; } + private setIsLoaded(value: boolean): void { + this.isLoaded = value; + } + private resetLimit(): void { this.datasource.setLimit(this.pageSize); } @@ -63,11 +75,8 @@ export class DatasourceController implements ReactiveController, QueryController return this.gate.props.datasource; } - get isLoading(): boolean { - if (this.isRefreshing || this.isFetchingNextBatch) { - return false; - } - return this.isDSLoading; + get isFirstLoad(): boolean { + return !this.isLoaded; } get isRefreshing(): boolean { @@ -105,6 +114,11 @@ export class DatasourceController implements ReactiveController, QueryController } setup(): () => void { + when( + () => !this.isDSLoading, + () => this.setIsLoaded(true) + ); + return autorun(() => { // Always use actions to set flags to avoid subscribing to them this.updateFlags(this.datasource.status); diff --git a/packages/shared/widget-plugin-grid/src/query/query-controller.ts b/packages/shared/widget-plugin-grid/src/query/query-controller.ts index bc20172a63..a3cf6a7214 100644 --- a/packages/shared/widget-plugin-grid/src/query/query-controller.ts +++ b/packages/shared/widget-plugin-grid/src/query/query-controller.ts @@ -15,7 +15,7 @@ export interface QueryController extends Pick { refresh(): void; setPageSize(size: number): void; hasMoreItems: boolean; - isLoading: boolean; + isFirstLoad: boolean; isRefreshing: boolean; isFetchingNextBatch: boolean; }