From 8cc3097f538b3d87968be9561624942faa228266 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 10 Jul 2026 11:10:12 -0400 Subject: [PATCH 01/22] feat(iModelGrid): add sort state persistence to IModelTable and IModelGrid components --- .../mui/IModelGridMUI.stories.tsx | 16 +++++ .../containers/iModelGrid/IModelGridMUI.tsx | 14 +++++ .../containers/iModelGrid/IModelTableMUI.tsx | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index ff857376..6f151f2e 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -158,6 +158,22 @@ TableViewWithOverrides.args = { }, }; +export const TableWithPersistedSort = Template.bind({}); +TableWithPersistedSort.args = { + ...baseArgs, + viewMode: "cells", + preserveSortState: true, + sortStateStorageKey: "storybook-imodel-table-sort", +}; +TableWithPersistedSort.parameters = { + docs: { + description: { + story: + "Sort a column, then reload the page. The sort state is restored from `localStorage` using `sortStateStorageKey`.", + }, + }, +}; + export const OverrideApiDataWithLoadMore: Story = withITwinIdOverride( withAccessTokenOverride((args) => { diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx index b813b43c..2bd488c4 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx @@ -103,6 +103,16 @@ export interface IModelGridMUIProps tileOverrides?: Partial; tableOverrides?: IModelTableOverridesMUI; stringsOverrides?: Partial; + /** + * When true, the table sort state is persisted to `localStorage` so it is + * restored on subsequent mounts. + */ + preserveSortState?: boolean; + /** + * `localStorage` key used to persist the table sort state when + * `preserveSortState` is true. + */ + sortStateStorageKey?: string; } /** @@ -149,6 +159,8 @@ const IModelGridInternal = ({ onRefetch, dataMode = "internal", disableAddToRecents = false, + preserveSortState, + sortStateStorageKey, }: IModelGridMUIProps) => { const [sort, setSort] = React.useState(sortOptions); @@ -408,6 +420,8 @@ const IModelGridInternal = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} + preserveSortState={preserveSortState} + sortStateStorageKey={sortStateStorageKey} data-testid="imodel-table" /> )} diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx index 82d86087..c33750c0 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx @@ -6,6 +6,7 @@ import { DataGrid, GRID_DEFAULT_LOCALE_TEXT, GridColDef, + GridSortModel, } from "@mui/x-data-grid"; import svgMore from "@stratakit/icons/more-vertical.svg"; import { Icon } from "@stratakit/mui"; @@ -62,8 +63,47 @@ export interface IModelTableMUIProps { isLoading?: boolean; /** Called when more data should be loaded. */ fetchMore?: (() => void) | false; + /** + * When true, the sort state is persisted to `localStorage` so it is restored + * on subsequent mounts. + */ + preserveSortState?: boolean; + /** + * `localStorage` key used to persist the sort state when `preserveSortState` + * is true. + * @default "imodel-table-sort-model" + */ + sortStateStorageKey?: string; } +const DEFAULT_SORT_STATE_STORAGE_KEY = "imodel-table-sort-model"; + +const readPersistedSortModel = (storageKey: string): GridSortModel => { + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GridSortModel) : []; + } catch { + return []; + } +}; + +const writePersistedSortModel = ( + storageKey: string, + sortModel: GridSortModel +) => { + try { + window.localStorage.setItem(storageKey, JSON.stringify(sortModel)); + } catch { + console.warn( + `Failed to persist sort model to localStorage under key "${storageKey}".` + ); + } +}; + /** * Table view for iModels using MUI X DataGrid (Community edition). */ @@ -79,6 +119,8 @@ export const IModelTableMUI = ({ } = {}, isLoading, fetchMore, + preserveSortState, + sortStateStorageKey = DEFAULT_SORT_STATE_STORAGE_KEY, }: IModelTableMUIProps) => { // Eagerly load all available data so the table has the full dataset // for client-side pagination and sorting. @@ -89,6 +131,20 @@ export const IModelTableMUI = ({ }, [fetchMore]); const favoritesContext = useIModelFavoritesContext(); + // Internal sort state, seeded from localStorage when persistence is enabled. + const [internalSortModel, setInternalSortModel] = + React.useState(() => + preserveSortState ? readPersistedSortModel(sortStateStorageKey) : [] + ); + + const handleSortModelChange = React.useCallback( + (newSortModel: GridSortModel) => { + setInternalSortModel(newSortModel); + writePersistedSortModel(sortStateStorageKey, newSortModel); + }, + [sortStateStorageKey] + ); + const columns = React.useMemo[]>(() => { const cols: (GridColDef | false)[] = [ !hideColumns.includes(IModelCellColumn.Favorite) && { @@ -188,6 +244,8 @@ export const IModelTableMUI = ({ rows={iModels} columns={columns} loading={isLoading} + sortModel={preserveSortState ? internalSortModel : undefined} + onSortModelChange={preserveSortState ? handleSortModelChange : undefined} onRowClick={ actions ? (params) => { From 137e2f67c5c64197792f49537bb15de437bc2513 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 10 Jul 2026 11:10:40 -0400 Subject: [PATCH 02/22] feat(iTwinTable): add sort state persistence to ITwinTable and ITwinGrid components --- .../mui/ITwinGridMUI.stories.tsx | 16 +++++ .../mui/containers/ITwinGrid/ITwinGridMUI.tsx | 14 +++++ .../containers/ITwinGrid/ITwinTableMUI.tsx | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 6c1006f7..d5a66a83 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -79,6 +79,22 @@ TableView.args = { ], }; +export const TableWithPersistedSort = Template.bind({}); +TableWithPersistedSort.args = { + ...baseArgs, + viewMode: "cells", + preserveSortState: true, + sortStateStorageKey: "storybook-itwin-table-sort", +}; +TableWithPersistedSort.parameters = { + docs: { + description: { + story: + "Sort a column, then reload the page. The sort state is restored from `localStorage` using `sortStateStorageKey`.", + }, + }, +}; + export const TableViewWithOverrides = Template.bind({}); TableViewWithOverrides.args = { ...baseArgs, diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index bce76405..8abf35c4 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -77,6 +77,16 @@ export interface ITwinGridPropsMUI tableOverrides?: ITwinTableOverridesMUI; /** Localized string overrides - falls back to default English strings if not provided */ stringsOverrides?: Partial; + /** + * When true, the table sort state is persisted to `localStorage` so it is + * restored on subsequent mounts. + */ + preserveSortState?: boolean; + /** + * `localStorage` key used to persist the table sort state when + * `preserveSortState` is true. + */ + sortStateStorageKey?: string; } /** @@ -99,6 +109,8 @@ export const ITwinGridMUI = ({ viewMode, tableOverrides, className, + preserveSortState, + sortStateStorageKey, }: ITwinGridPropsMUI) => { const { iTwinFavorites, @@ -287,6 +299,8 @@ export const ITwinGridMUI = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} + preserveSortState={preserveSortState} + sortStateStorageKey={sortStateStorageKey} /> ); }; diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx index a5c93f7e..257f866e 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx @@ -6,6 +6,7 @@ import { DataGrid, GRID_DEFAULT_LOCALE_TEXT, GridColDef, + GridSortModel, } from "@mui/x-data-grid"; import svgMore from "@stratakit/icons/more-vertical.svg"; import { Icon } from "@stratakit/mui"; @@ -65,8 +66,47 @@ export interface ITwinTableMUIProps { isLoading?: boolean; /** Called when more data should be loaded. */ fetchMore?: (() => void) | false; + /** + * When true, the sort state is persisted to `localStorage` so it is restored + * on subsequent mounts. + */ + preserveSortState?: boolean; + /** + * `localStorage` key used to persist the sort state when `preserveSortState` + * is true. + * @default "itwin-table-sort-model" + */ + sortStateStorageKey?: string; } +const DEFAULT_SORT_STATE_STORAGE_KEY = "itwin-table-sort-model"; + +const readPersistedSortModel = (storageKey: string): GridSortModel => { + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GridSortModel) : []; + } catch { + return []; + } +}; + +const writePersistedSortModel = ( + storageKey: string, + sortModel: GridSortModel +) => { + try { + window.localStorage.setItem(storageKey, JSON.stringify(sortModel)); + } catch { + console.warn( + `Failed to persist sort model to localStorage under key "${storageKey}".` + ); + } +}; + /** * Table view for iTwins using MUI X DataGrid (Community edition). */ @@ -85,6 +125,8 @@ export const ITwinTableMUI = ({ } = {}, isLoading, fetchMore, + preserveSortState, + sortStateStorageKey = DEFAULT_SORT_STATE_STORAGE_KEY, }: ITwinTableMUIProps) => { // Eagerly load all available data so the table has the full dataset // for client-side pagination and sorting. @@ -93,6 +135,20 @@ export const ITwinTableMUI = ({ fetchMore(); } }, [fetchMore]); + + // Internal sort state, seeded from localStorage when persistence is enabled. + const [internalSortModel, setInternalSortModel] = + React.useState(() => + preserveSortState ? readPersistedSortModel(sortStateStorageKey) : [] + ); + + const handleSortModelChange = React.useCallback( + (newSortModel: GridSortModel) => { + setInternalSortModel(newSortModel); + writePersistedSortModel(sortStateStorageKey, newSortModel); + }, + [sortStateStorageKey] + ); const columns = React.useMemo[]>(() => { const cols: (GridColDef | false)[] = [ !hideColumns.includes(ITwinCellColumn.Favorite) && { @@ -188,6 +244,8 @@ export const ITwinTableMUI = ({ rows={iTwins} columns={columns} loading={isLoading} + sortModel={preserveSortState ? internalSortModel : undefined} + onSortModelChange={preserveSortState ? handleSortModelChange : undefined} onRowClick={ actions ? (params) => { From 5eb72dbf9f3b51a8564a7c5b46df255db48d43ef Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 10 Jul 2026 11:53:51 -0400 Subject: [PATCH 03/22] feat: implement controlled sort model for IModelGrid and ITwinGrid components --- .../mui/IModelGridMUI.stories.tsx | 36 +++++++++-- .../mui/ITwinGridMUI.stories.tsx | 34 ++++++++-- .../mui/containers/ITwinGrid/ITwinGridMUI.tsx | 23 ++++--- .../containers/ITwinGrid/ITwinTableMUI.tsx | 64 +++---------------- .../containers/iModelGrid/IModelGridMUI.tsx | 23 ++++--- .../containers/iModelGrid/IModelTableMUI.tsx | 64 +++---------------- 6 files changed, 96 insertions(+), 148 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index 6f151f2e..f2a29931 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -13,6 +13,7 @@ import Avatar from "@mui/material/Avatar"; import AvatarGroup from "@mui/material/AvatarGroup"; import Chip from "@mui/material/Chip"; import Typography from "@mui/material/Typography"; +import { GridSortModel } from "@mui/x-data-grid"; import { action } from "@storybook/addon-actions"; import { Meta, Story } from "@storybook/react/types-6-0"; import SvgDelete from "@stratakit/icons/delete.svg"; @@ -158,18 +159,39 @@ TableViewWithOverrides.args = { }, }; -export const TableWithPersistedSort = Template.bind({}); -TableWithPersistedSort.args = { +export const TableWithControlledSort: Story = + withITwinIdOverride( + withAccessTokenOverride((args) => { + const [sortModel, setSortModel] = React.useState([ + { field: "name", sort: "desc" }, + ]); + + return ( +
+ + Controlled sort model: {JSON.stringify(sortModel)} + + { + action("sort model changed")(newSortModel); + setSortModel(newSortModel); + }} + /> +
+ ); + }) + ); +TableWithControlledSort.args = { ...baseArgs, - viewMode: "cells", - preserveSortState: true, - sortStateStorageKey: "storybook-imodel-table-sort", }; -TableWithPersistedSort.parameters = { +TableWithControlledSort.parameters = { docs: { description: { story: - "Sort a column, then reload the page. The sort state is restored from `localStorage` using `sortStateStorageKey`.", + "The sort state is fully controlled by the parent via `sortModel` and `onSortModelChange`, so it can be saved anywhere the consumer wants.", }, }, }; diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index d5a66a83..f68394a0 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -18,6 +18,7 @@ import AvatarGroup from "@mui/material/AvatarGroup"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; import Typography from "@mui/material/Typography"; +import { GridSortModel } from "@mui/x-data-grid"; import { action } from "@storybook/addon-actions"; import { Meta, Story } from "@storybook/react/types-6-0"; import React from "react"; @@ -79,18 +80,37 @@ TableView.args = { ], }; -export const TableWithPersistedSort = Template.bind({}); -TableWithPersistedSort.args = { +export const TableWithControlledSort: Story = + withAccessTokenOverride((args) => { + const [sortModel, setSortModel] = React.useState([ + { field: "number", sort: "desc" }, + ]); + + return ( +
+ + Controlled sort model: {JSON.stringify(sortModel)} + + { + action("sort model changed")(newSortModel); + setSortModel(newSortModel); + }} + /> +
+ ); + }); +TableWithControlledSort.args = { ...baseArgs, - viewMode: "cells", - preserveSortState: true, - sortStateStorageKey: "storybook-itwin-table-sort", }; -TableWithPersistedSort.parameters = { +TableWithControlledSort.parameters = { docs: { description: { story: - "Sort a column, then reload the page. The sort state is restored from `localStorage` using `sortStateStorageKey`.", + "The sort state is fully controlled by the parent via `sortModel` and `onSortModelChange`, so it can be saved anywhere the consumer wants.", }, }, }; diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index 8abf35c4..a635137d 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -3,6 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import Box from "@mui/material/Box"; +import { GridSortModel } from "@mui/x-data-grid"; import React from "react"; import { InView } from "react-intersection-observer"; @@ -78,15 +79,13 @@ export interface ITwinGridPropsMUI /** Localized string overrides - falls back to default English strings if not provided */ stringsOverrides?: Partial; /** - * When true, the table sort state is persisted to `localStorage` so it is - * restored on subsequent mounts. + * Controlled sort model for the table view. When provided, the table's sort + * state is fully controlled by the parent and must be kept in sync via + * `onSortModelChange`. */ - preserveSortState?: boolean; - /** - * `localStorage` key used to persist the table sort state when - * `preserveSortState` is true. - */ - sortStateStorageKey?: string; + sortModel?: GridSortModel; + /** Called whenever the table sort model changes. */ + onSortModelChange?: (sortModel: GridSortModel) => void; } /** @@ -109,8 +108,8 @@ export const ITwinGridMUI = ({ viewMode, tableOverrides, className, - preserveSortState, - sortStateStorageKey, + sortModel, + onSortModelChange, }: ITwinGridPropsMUI) => { const { iTwinFavorites, @@ -299,8 +298,8 @@ export const ITwinGridMUI = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} - preserveSortState={preserveSortState} - sortStateStorageKey={sortStateStorageKey} + sortModel={sortModel} + onSortModelChange={onSortModelChange} /> ); }; diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx index 257f866e..aca2c599 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx @@ -67,46 +67,14 @@ export interface ITwinTableMUIProps { /** Called when more data should be loaded. */ fetchMore?: (() => void) | false; /** - * When true, the sort state is persisted to `localStorage` so it is restored - * on subsequent mounts. + * Controlled sort model. When provided, the table's sort state is fully + * controlled by the parent and must be kept in sync via `onSortModelChange`. */ - preserveSortState?: boolean; - /** - * `localStorage` key used to persist the sort state when `preserveSortState` - * is true. - * @default "itwin-table-sort-model" - */ - sortStateStorageKey?: string; + sortModel?: GridSortModel; + /** Called whenever the sort model changes. */ + onSortModelChange?: (sortModel: GridSortModel) => void; } -const DEFAULT_SORT_STATE_STORAGE_KEY = "itwin-table-sort-model"; - -const readPersistedSortModel = (storageKey: string): GridSortModel => { - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as GridSortModel) : []; - } catch { - return []; - } -}; - -const writePersistedSortModel = ( - storageKey: string, - sortModel: GridSortModel -) => { - try { - window.localStorage.setItem(storageKey, JSON.stringify(sortModel)); - } catch { - console.warn( - `Failed to persist sort model to localStorage under key "${storageKey}".` - ); - } -}; - /** * Table view for iTwins using MUI X DataGrid (Community edition). */ @@ -125,8 +93,8 @@ export const ITwinTableMUI = ({ } = {}, isLoading, fetchMore, - preserveSortState, - sortStateStorageKey = DEFAULT_SORT_STATE_STORAGE_KEY, + sortModel, + onSortModelChange, }: ITwinTableMUIProps) => { // Eagerly load all available data so the table has the full dataset // for client-side pagination and sorting. @@ -135,20 +103,6 @@ export const ITwinTableMUI = ({ fetchMore(); } }, [fetchMore]); - - // Internal sort state, seeded from localStorage when persistence is enabled. - const [internalSortModel, setInternalSortModel] = - React.useState(() => - preserveSortState ? readPersistedSortModel(sortStateStorageKey) : [] - ); - - const handleSortModelChange = React.useCallback( - (newSortModel: GridSortModel) => { - setInternalSortModel(newSortModel); - writePersistedSortModel(sortStateStorageKey, newSortModel); - }, - [sortStateStorageKey] - ); const columns = React.useMemo[]>(() => { const cols: (GridColDef | false)[] = [ !hideColumns.includes(ITwinCellColumn.Favorite) && { @@ -244,8 +198,8 @@ export const ITwinTableMUI = ({ rows={iTwins} columns={columns} loading={isLoading} - sortModel={preserveSortState ? internalSortModel : undefined} - onSortModelChange={preserveSortState ? handleSortModelChange : undefined} + sortModel={sortModel} + onSortModelChange={onSortModelChange} onRowClick={ actions ? (params) => { diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx index 2bd488c4..b64eb741 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx @@ -3,6 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import Box from "@mui/material/Box"; +import { GridSortModel } from "@mui/x-data-grid"; import React from "react"; import { InView } from "react-intersection-observer"; @@ -104,15 +105,13 @@ export interface IModelGridMUIProps tableOverrides?: IModelTableOverridesMUI; stringsOverrides?: Partial; /** - * When true, the table sort state is persisted to `localStorage` so it is - * restored on subsequent mounts. + * Controlled sort model for the table view. When provided, the table's sort + * state is fully controlled by the parent and must be kept in sync via + * `onSortModelChange`. */ - preserveSortState?: boolean; - /** - * `localStorage` key used to persist the table sort state when - * `preserveSortState` is true. - */ - sortStateStorageKey?: string; + sortModel?: GridSortModel; + /** Called whenever the table sort model changes. */ + onSortModelChange?: (sortModel: GridSortModel) => void; } /** @@ -159,8 +158,8 @@ const IModelGridInternal = ({ onRefetch, dataMode = "internal", disableAddToRecents = false, - preserveSortState, - sortStateStorageKey, + sortModel, + onSortModelChange, }: IModelGridMUIProps) => { const [sort, setSort] = React.useState(sortOptions); @@ -420,8 +419,8 @@ const IModelGridInternal = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} - preserveSortState={preserveSortState} - sortStateStorageKey={sortStateStorageKey} + sortModel={sortModel} + onSortModelChange={onSortModelChange} data-testid="imodel-table" /> )} diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx index c33750c0..5dadd834 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx @@ -64,46 +64,14 @@ export interface IModelTableMUIProps { /** Called when more data should be loaded. */ fetchMore?: (() => void) | false; /** - * When true, the sort state is persisted to `localStorage` so it is restored - * on subsequent mounts. + * Controlled sort model. When provided, the table's sort state is fully + * controlled by the parent and must be kept in sync via `onSortModelChange`. */ - preserveSortState?: boolean; - /** - * `localStorage` key used to persist the sort state when `preserveSortState` - * is true. - * @default "imodel-table-sort-model" - */ - sortStateStorageKey?: string; + sortModel?: GridSortModel; + /** Called whenever the sort model changes. */ + onSortModelChange?: (sortModel: GridSortModel) => void; } -const DEFAULT_SORT_STATE_STORAGE_KEY = "imodel-table-sort-model"; - -const readPersistedSortModel = (storageKey: string): GridSortModel => { - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as GridSortModel) : []; - } catch { - return []; - } -}; - -const writePersistedSortModel = ( - storageKey: string, - sortModel: GridSortModel -) => { - try { - window.localStorage.setItem(storageKey, JSON.stringify(sortModel)); - } catch { - console.warn( - `Failed to persist sort model to localStorage under key "${storageKey}".` - ); - } -}; - /** * Table view for iModels using MUI X DataGrid (Community edition). */ @@ -119,8 +87,8 @@ export const IModelTableMUI = ({ } = {}, isLoading, fetchMore, - preserveSortState, - sortStateStorageKey = DEFAULT_SORT_STATE_STORAGE_KEY, + sortModel, + onSortModelChange, }: IModelTableMUIProps) => { // Eagerly load all available data so the table has the full dataset // for client-side pagination and sorting. @@ -131,20 +99,6 @@ export const IModelTableMUI = ({ }, [fetchMore]); const favoritesContext = useIModelFavoritesContext(); - // Internal sort state, seeded from localStorage when persistence is enabled. - const [internalSortModel, setInternalSortModel] = - React.useState(() => - preserveSortState ? readPersistedSortModel(sortStateStorageKey) : [] - ); - - const handleSortModelChange = React.useCallback( - (newSortModel: GridSortModel) => { - setInternalSortModel(newSortModel); - writePersistedSortModel(sortStateStorageKey, newSortModel); - }, - [sortStateStorageKey] - ); - const columns = React.useMemo[]>(() => { const cols: (GridColDef | false)[] = [ !hideColumns.includes(IModelCellColumn.Favorite) && { @@ -244,8 +198,8 @@ export const IModelTableMUI = ({ rows={iModels} columns={columns} loading={isLoading} - sortModel={preserveSortState ? internalSortModel : undefined} - onSortModelChange={preserveSortState ? handleSortModelChange : undefined} + sortModel={sortModel} + onSortModelChange={onSortModelChange} onRowClick={ actions ? (params) => { From 553f764e74260903a211d8ae7668542651e9949f Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 10 Jul 2026 14:01:06 -0400 Subject: [PATCH 04/22] feat(iModelBrowser): add controlled table sorting for iTwin/iModel tables --- .../omar-save-sort-imodel-table_2026-07-10-18-00.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json diff --git a/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json b/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json new file mode 100644 index 00000000..2fe0b56f --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Controlled table sorting for iTwin/iModel tables", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} \ No newline at end of file From e279c8460717a7c30703fb07ccab352cd0189a20 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 30 Jul 2026 14:32:32 -0400 Subject: [PATCH 05/22] feat: enhance sorting functionality in IModelGrid components --- .../mui/IModelGridMUI.stories.tsx | 77 ++++++++++++++++--- .../containers/iModelGrid/useIModelSort.ts | 2 +- .../imodel-browser/src/jest-globals.d.ts | 6 ++ .../containers/iModelGrid/IModelGridMUI.tsx | 38 +++++---- .../containers/iModelGrid/IModelTableMUI.tsx | 2 + packages/modules/imodel-browser/src/types.ts | 2 +- 6 files changed, 99 insertions(+), 28 deletions(-) create mode 100644 packages/modules/imodel-browser/src/jest-globals.d.ts diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index f2a29931..ac0c6046 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -8,6 +8,7 @@ import { DataStatus, IModelCellColumn, IModelGrid as ExternalComponent, + IModelSortOptions, } from "@itwin/imodel-browser-react/mui"; import Avatar from "@mui/material/Avatar"; import AvatarGroup from "@mui/material/AvatarGroup"; @@ -162,22 +163,76 @@ TableViewWithOverrides.args = { export const TableWithControlledSort: Story = withITwinIdOverride( withAccessTokenOverride((args) => { - const [sortModel, setSortModel] = React.useState([ - { field: "name", sort: "desc" }, - ]); + const [sortModel, setSortModel] = React.useState({ + sortType: "name", + descending: true, + }); return (
- - Controlled sort model: {JSON.stringify(sortModel)} - +
+ Sort by: + + setSortModel((prev) => ({ ...prev, sortType: "name" })) + } + /> + + setSortModel((prev) => ({ + ...prev, + sortType: "lastChangesetPushDateTime", + })) + } + /> + + setSortModel((prev) => ({ + ...prev, + descending: !prev.descending, + })) + } + /> +
{ - action("sort model changed")(newSortModel); - setSortModel(newSortModel); + sortOptions={sortModel} + onSortModelChange={(newSortModel: GridSortModel) => { + if (newSortModel.length > 0) { + const newSort = newSortModel[0]; + setSortModel({ + sortType: newSort.field as + | "name" + | "lastChangesetPushDateTime", + descending: newSort.sort === "desc", + }); + } }} />
diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index 62d95295..b214f348 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -21,7 +21,7 @@ function isSupportedSortType( "name", "description", "initialized", - "createdDateTime", + "lastChangesetPushDateTime", ].includes(sortType) ); } diff --git a/packages/modules/imodel-browser/src/jest-globals.d.ts b/packages/modules/imodel-browser/src/jest-globals.d.ts new file mode 100644 index 00000000..d80f7eef --- /dev/null +++ b/packages/modules/imodel-browser/src/jest-globals.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/// +/// diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx index b64eb741..338161bc 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx @@ -18,6 +18,7 @@ import { type ApiOverrides, type IModelFull, DataStatus, + IModelCellColumn, IModelSortOptions, } from "../../../types"; import { _mergeStrings } from "../../../utils/_apiOverrides"; @@ -104,12 +105,6 @@ export interface IModelGridMUIProps tileOverrides?: Partial; tableOverrides?: IModelTableOverridesMUI; stringsOverrides?: Partial; - /** - * Controlled sort model for the table view. When provided, the table's sort - * state is fully controlled by the parent and must be kept in sync via - * `onSortModelChange`. - */ - sortModel?: GridSortModel; /** Called whenever the table sort model changes. */ onSortModelChange?: (sortModel: GridSortModel) => void; } @@ -158,13 +153,10 @@ const IModelGridInternal = ({ onRefetch, dataMode = "internal", disableAddToRecents = false, - sortModel, onSortModelChange, }: IModelGridMUIProps) => { - const [sort, setSort] = React.useState(sortOptions); - - React.useEffect(() => { - setSort( + const sort = React.useMemo( + () => viewMode === "cells" ? { sortType: "name", @@ -173,9 +165,25 @@ const IModelGridInternal = ({ : { sortType: sortOptions.sortType, descending: sortOptions.descending, - } - ); - }, [sortOptions.descending, sortOptions.sortType, viewMode]); + }, + [sortOptions.descending, sortOptions.sortType, viewMode] + ); + + // Translate the `sortOptions` prop into the equivalent DataGrid sort model so + // the table view reflects the requested sort without reordering the fetched + // list (which keeps its default sort). + const initialTableSortModel = React.useMemo( + () => [ + { + field: + sortOptions.sortType === "lastChangesetPushDateTime" + ? IModelCellColumn.LastModified + : IModelCellColumn.Name, + sort: sortOptions.descending ? "desc" : "asc", + }, + ], + [sortOptions.sortType, sortOptions.descending] + ); const strings = React.useMemo( () => @@ -419,8 +427,8 @@ const IModelGridInternal = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} - sortModel={sortModel} onSortModelChange={onSortModelChange} + sortModel={initialTableSortModel} data-testid="imodel-table" /> )} diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx index 5dadd834..26b05976 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx @@ -200,6 +200,7 @@ export const IModelTableMUI = ({ loading={isLoading} sortModel={sortModel} onSortModelChange={onSortModelChange} + sortingOrder={sortModel ? ["asc", "desc"] : ["asc", "desc", null]} onRowClick={ actions ? (params) => { @@ -233,6 +234,7 @@ export const IModelTableMUI = ({ disableColumnFilter initialState={{ pagination: { paginationModel: { pageSize: 25 } }, + ...(sortModel ? { sorting: { sortModel } } : {}), }} pageSizeOptions={[25, 50, 100]} localeText={{ diff --git a/packages/modules/imodel-browser/src/types.ts b/packages/modules/imodel-browser/src/types.ts index 1bb2755e..1393bb0c 100644 --- a/packages/modules/imodel-browser/src/types.ts +++ b/packages/modules/imodel-browser/src/types.ts @@ -97,7 +97,7 @@ export type DataMode = "internal" | "external"; type SortOptions = { sortType: K; descending: boolean }; /** Supported IModel sorting types */ -export type IModelSortOptionsKeys = "name" | "createdDateTime"; +export type IModelSortOptionsKeys = "name" | "lastChangesetPushDateTime"; /** Object/function that configure IModel sorting behavior. */ export type IModelSortOptions = SortOptions; From 9342a0684af7693452f0a7f8d68d169ca4742da2 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 30 Jul 2026 16:09:56 -0400 Subject: [PATCH 06/22] feat: enhance sorting capabilities for IModel and ITwin tables with new sort models --- .../mui/IModelGridMUI.stories.tsx | 14 +---- .../mui/ITwinGridMUI.stories.tsx | 62 +++++++++++++++---- .../containers/iModelGrid/useIModelSort.ts | 10 ++- .../mui/containers/ITwinGrid/ITwinGridMUI.tsx | 59 ++++++++++++++---- .../containers/ITwinGrid/ITwinTableMUI.tsx | 16 +++-- .../containers/iModelGrid/IModelGridMUI.tsx | 36 +++++++---- .../containers/iModelGrid/IModelTableMUI.tsx | 14 +++-- .../iModelGrid/clientSideIModelSort.ts | 2 +- .../modules/imodel-browser/src/mui/index.ts | 11 +++- .../modules/imodel-browser/src/mui/types.ts | 26 +++++++- 10 files changed, 189 insertions(+), 61 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index ac0c6046..5834b364 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -5,6 +5,7 @@ import { type IModelFull, type IModelGridProps as IModelGridMUIProps, + type IModelTableSortModel, DataStatus, IModelCellColumn, IModelGrid as ExternalComponent, @@ -14,7 +15,6 @@ import Avatar from "@mui/material/Avatar"; import AvatarGroup from "@mui/material/AvatarGroup"; import Chip from "@mui/material/Chip"; import Typography from "@mui/material/Typography"; -import { GridSortModel } from "@mui/x-data-grid"; import { action } from "@storybook/addon-actions"; import { Meta, Story } from "@storybook/react/types-6-0"; import SvgDelete from "@stratakit/icons/delete.svg"; @@ -182,7 +182,6 @@ export const TableWithControlledSort: Story = setSortModel((prev) => ({ ...prev, sortType: "name" })) @@ -191,11 +190,6 @@ export const TableWithControlledSort: Story = = { + onSortModelChange={(newSortModel: IModelTableSortModel) => { if (newSortModel.length > 0) { const newSort = newSortModel[0]; setSortModel({ - sortType: newSort.field as - | "name" - | "lastChangesetPushDateTime", + sortType: newSort.field, descending: newSort.sort === "desc", }); } diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index f68394a0..5182c593 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -6,6 +6,7 @@ import { type IndividualITwinStateHook, type ITwinFull, type ITwinGridProps, + type ITwinTableSortModel, DataStatus, ITwinCellColumn, ITwinGrid as ExternalComponent, @@ -18,7 +19,6 @@ import AvatarGroup from "@mui/material/AvatarGroup"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; import Typography from "@mui/material/Typography"; -import { GridSortModel } from "@mui/x-data-grid"; import { action } from "@storybook/addon-actions"; import { Meta, Story } from "@storybook/react/types-6-0"; import React from "react"; @@ -82,22 +82,60 @@ TableView.args = { export const TableWithControlledSort: Story = withAccessTokenOverride((args) => { - const [sortModel, setSortModel] = React.useState([ - { field: "number", sort: "desc" }, - ]); + const [orderbyOptions, setOrderbyOptions] = React.useState("number desc"); + const [field, direction] = orderbyOptions.split(/\s+/); return (
- - Controlled sort model: {JSON.stringify(sortModel)} - +
+ Sort by: + setOrderbyOptions(`number ${direction}`)} + /> + setOrderbyOptions(`displayName ${direction}`)} + /> + + setOrderbyOptions(`lastModifiedDateTime ${direction}`) + } + /> + + setOrderbyOptions( + `${field} ${direction === "desc" ? "asc" : "desc"}` + ) + } + /> +
{ + orderbyOptions={orderbyOptions} + onSortModelChange={(newSortModel: ITwinTableSortModel) => { action("sort model changed")(newSortModel); - setSortModel(newSortModel); + if (newSortModel.length > 0) { + const newSort = newSortModel[0]; + setOrderbyOptions(`${newSort.field} ${newSort.sort ?? "asc"}`); + } }} />
@@ -110,7 +148,7 @@ TableWithControlledSort.parameters = { docs: { description: { story: - "The sort state is fully controlled by the parent via `sortModel` and `onSortModelChange`, so it can be saved anywhere the consumer wants.", + "The initial table sort is derived from the `orderbyOptions` prop, and changes are reported via `onSortModelChange` so the sort state can be saved anywhere the consumer wants.", }, }, }; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index b214f348..c72e6455 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -53,8 +53,14 @@ export const useIModelSort = ( } const sorted = [...iModels].sort( (iModelA: IModelFull, iModelB: IModelFull) => { - const a = iModelA[sortType]; - const b = iModelB[sortType]; + const a = + sortType === "lastChangesetPushDateTime" + ? iModelA.lastChangesetPushDateTime ?? iModelA.createdDateTime + : iModelA[sortType]; + const b = + sortType === "lastChangesetPushDateTime" + ? iModelB.lastChangesetPushDateTime ?? iModelB.createdDateTime + : iModelB[sortType]; if (typeof a === "boolean" || typeof b === "boolean" || a === b) { return sortBooleanOrEqualValues(a, b); } diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index a635137d..41f9e43b 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -3,7 +3,6 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import Box from "@mui/material/Box"; -import { GridSortModel } from "@mui/x-data-grid"; import React from "react"; import { InView } from "react-intersection-observer"; @@ -22,7 +21,10 @@ import { } from "../../../utils/_buildMenuOptions"; import { BaseCardLoading } from "../../components/baseCard/BaseCardLoading"; import { NoResultsMUI } from "../../components/noResults/NoResultsMUI"; -import { type ITwinTableOverridesMUI } from "../../types"; +import { + type ITwinTableOverridesMUI, + type ITwinTableSortModel, +} from "../../types"; import { stripNonTileProps } from "../../utils/stripNonTileProps"; import { type ITwinTableMUIStrings, ITwinTableMUI } from "./ITwinTableMUI"; import { type ITwinTilePropsMUI, ITwinTileMUI } from "./ITwinTileMUI"; @@ -78,14 +80,8 @@ export interface ITwinGridPropsMUI tableOverrides?: ITwinTableOverridesMUI; /** Localized string overrides - falls back to default English strings if not provided */ stringsOverrides?: Partial; - /** - * Controlled sort model for the table view. When provided, the table's sort - * state is fully controlled by the parent and must be kept in sync via - * `onSortModelChange`. - */ - sortModel?: GridSortModel; /** Called whenever the table sort model changes. */ - onSortModelChange?: (sortModel: GridSortModel) => void; + onSortModelChange?: (sortModel: ITwinTableSortModel) => void; } /** @@ -108,7 +104,6 @@ export const ITwinGridMUI = ({ viewMode, tableOverrides, className, - sortModel, onSortModelChange, }: ITwinGridPropsMUI) => { const { @@ -119,6 +114,46 @@ export const ITwinGridMUI = ({ resetShouldRefetchFavorites, } = useITwinFavorites(accessToken, apiOverrides?.serverEnvironmentPrefix); + // Translate the `orderbyOptions` prop (an OData `$orderby` string such as + // "number DESC" or "displayName ASC") into the equivalent DataGrid sort model + // so the table view reflects the requested sort without reordering the fetched + // list (which keeps its default sort). Left undefined when no sort is + // requested so the table stays uncontrolled. + const initialTableSortModel = React.useMemo< + ITwinTableSortModel | undefined + >(() => { + if (!orderbyOptions) { + return undefined; + } + const [field, direction] = orderbyOptions.split(",")[0].trim().split(/\s+/); + if (!field) { + return undefined; + } + return [ + { + field: field as ITwinTableSortModel[number]["field"], + sort: direction?.toLowerCase() === "desc" ? "desc" : "asc", + }, + ]; + }, [orderbyOptions]); + + // Own the sort state so column-header clicks re-sort the table even when the + // consumer does not control it, while staying in sync with the prop-derived + // sort and forwarding changes through `onSortModelChange`. + const [tableSortModel, setTableSortModel] = React.useState< + ITwinTableSortModel | undefined + >(initialTableSortModel); + React.useEffect(() => { + setTableSortModel(initialTableSortModel); + }, [initialTableSortModel]); + const handleSortModelChange = React.useCallback( + (model: ITwinTableSortModel) => { + setTableSortModel(model); + onSortModelChange?.(model); + }, + [onSortModelChange] + ); + const strings = React.useMemo( () => _mergeStrings( @@ -298,8 +333,8 @@ export const ITwinGridMUI = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} - sortModel={sortModel} - onSortModelChange={onSortModelChange} + sortModel={tableSortModel} + onSortModelChange={handleSortModelChange} /> ); }; diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx index aca2c599..d2354e30 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx @@ -6,7 +6,6 @@ import { DataGrid, GRID_DEFAULT_LOCALE_TEXT, GridColDef, - GridSortModel, } from "@mui/x-data-grid"; import svgMore from "@stratakit/icons/more-vertical.svg"; import { Icon } from "@stratakit/mui"; @@ -22,7 +21,10 @@ import { import { formatDate } from "../../../utils/formatDate"; import MoreMenuMUI from "../../components/MoreMenuMUI"; import { FavoriteIconMUI } from "../../components/tileFavoriteIcon/FavoriteIconMUI"; -import { type ITwinTableOverridesMUI } from "../../types"; +import { + type ITwinTableOverridesMUI, + type ITwinTableSortModel, +} from "../../types"; const EMPTY_COLUMN_OVERRIDES: NonNullable< ITwinTableOverridesMUI["columnOverrides"] @@ -70,9 +72,9 @@ export interface ITwinTableMUIProps { * Controlled sort model. When provided, the table's sort state is fully * controlled by the parent and must be kept in sync via `onSortModelChange`. */ - sortModel?: GridSortModel; + sortModel?: ITwinTableSortModel; /** Called whenever the sort model changes. */ - onSortModelChange?: (sortModel: GridSortModel) => void; + onSortModelChange?: (sortModel: ITwinTableSortModel) => void; } /** @@ -199,7 +201,10 @@ export const ITwinTableMUI = ({ columns={columns} loading={isLoading} sortModel={sortModel} - onSortModelChange={onSortModelChange} + onSortModelChange={(model) => + onSortModelChange?.([...model] as ITwinTableSortModel) + } + sortingOrder={sortModel ? ["asc", "desc"] : ["asc", "desc", null]} onRowClick={ actions ? (params) => { @@ -233,6 +238,7 @@ export const ITwinTableMUI = ({ disableColumnFilter initialState={{ pagination: { paginationModel: { pageSize: 25 } }, + ...(sortModel ? { sorting: { sortModel } } : {}), }} pageSizeOptions={[25, 50, 100]} localeText={{ diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx index 338161bc..86d59d50 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx @@ -3,7 +3,6 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import Box from "@mui/material/Box"; -import { GridSortModel } from "@mui/x-data-grid"; import React from "react"; import { InView } from "react-intersection-observer"; @@ -18,7 +17,6 @@ import { type ApiOverrides, type IModelFull, DataStatus, - IModelCellColumn, IModelSortOptions, } from "../../../types"; import { _mergeStrings } from "../../../utils/_apiOverrides"; @@ -33,7 +31,10 @@ import { } from "../../../utils/iModelApi"; import { BaseCardLoading } from "../../components/baseCard/BaseCardLoading"; import { NoResultsMUI as NoResults } from "../../components/noResults/NoResultsMUI"; -import { type IModelTableOverridesMUI } from "../../types"; +import { + type IModelTableOverridesMUI, + type IModelTableSortModel, +} from "../../types"; import { stripNonTileProps } from "../../utils/stripNonTileProps"; import { type IModelTileMUIProps, @@ -106,7 +107,7 @@ export interface IModelGridMUIProps tableOverrides?: IModelTableOverridesMUI; stringsOverrides?: Partial; /** Called whenever the table sort model changes. */ - onSortModelChange?: (sortModel: GridSortModel) => void; + onSortModelChange?: (sortModel: IModelTableSortModel) => void; } /** @@ -172,19 +173,32 @@ const IModelGridInternal = ({ // Translate the `sortOptions` prop into the equivalent DataGrid sort model so // the table view reflects the requested sort without reordering the fetched // list (which keeps its default sort). - const initialTableSortModel = React.useMemo( + const initialTableSortModel = React.useMemo( () => [ { - field: - sortOptions.sortType === "lastChangesetPushDateTime" - ? IModelCellColumn.LastModified - : IModelCellColumn.Name, + field: sortOptions.sortType, sort: sortOptions.descending ? "desc" : "asc", }, ], [sortOptions.sortType, sortOptions.descending] ); + // Own the sort state so column-header clicks re-sort the table even when the + // consumer does not control it, while staying in sync with the prop-derived + // sort and forwarding changes through `onSortModelChange`. + const [tableSortModel, setTableSortModel] = + React.useState(initialTableSortModel); + React.useEffect(() => { + setTableSortModel(initialTableSortModel); + }, [initialTableSortModel]); + const handleSortModelChange = React.useCallback( + (model: IModelTableSortModel) => { + setTableSortModel(model); + onSortModelChange?.(model); + }, + [onSortModelChange] + ); + const strings = React.useMemo( () => _mergeStrings( @@ -427,8 +441,8 @@ const IModelGridInternal = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} - onSortModelChange={onSortModelChange} - sortModel={initialTableSortModel} + onSortModelChange={handleSortModelChange} + sortModel={tableSortModel} data-testid="imodel-table" /> )} diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx index 26b05976..071659bd 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx @@ -6,7 +6,6 @@ import { DataGrid, GRID_DEFAULT_LOCALE_TEXT, GridColDef, - GridSortModel, } from "@mui/x-data-grid"; import svgMore from "@stratakit/icons/more-vertical.svg"; import { Icon } from "@stratakit/mui"; @@ -23,7 +22,10 @@ import { import { formatDate } from "../../../utils/formatDate"; import MoreMenuMUI from "../../components/MoreMenuMUI"; import { FavoriteIconMUI } from "../../components/tileFavoriteIcon/FavoriteIconMUI"; -import { type IModelTableOverridesMUI } from "../../types"; +import { + type IModelTableOverridesMUI, + type IModelTableSortModel, +} from "../../types"; const EMPTY_COLUMN_OVERRIDES: NonNullable< IModelTableOverridesMUI["columnOverrides"] @@ -67,9 +69,9 @@ export interface IModelTableMUIProps { * Controlled sort model. When provided, the table's sort state is fully * controlled by the parent and must be kept in sync via `onSortModelChange`. */ - sortModel?: GridSortModel; + sortModel?: IModelTableSortModel; /** Called whenever the sort model changes. */ - onSortModelChange?: (sortModel: GridSortModel) => void; + onSortModelChange?: (sortModel: IModelTableSortModel) => void; } /** @@ -199,7 +201,9 @@ export const IModelTableMUI = ({ columns={columns} loading={isLoading} sortModel={sortModel} - onSortModelChange={onSortModelChange} + onSortModelChange={(model) => + onSortModelChange?.([...model] as IModelTableSortModel) + } sortingOrder={sortModel ? ["asc", "desc"] : ["asc", "desc", null]} onRowClick={ actions diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/clientSideIModelSort.ts b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/clientSideIModelSort.ts index cfa4180e..bb5ed518 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/clientSideIModelSort.ts +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/clientSideIModelSort.ts @@ -30,7 +30,7 @@ export const clientSideIModelSort = ( const currValue = sort.sortType === "name" ? iModel.displayName ?? iModel.name ?? "" - : iModel[sort.sortType] ?? ""; + : iModel.lastChangesetPushDateTime ?? iModel.createdDateTime ?? ""; return currValue.toLocaleLowerCase(); }; diff --git a/packages/modules/imodel-browser/src/mui/index.ts b/packages/modules/imodel-browser/src/mui/index.ts index 0402e731..f49f40fa 100644 --- a/packages/modules/imodel-browser/src/mui/index.ts +++ b/packages/modules/imodel-browser/src/mui/index.ts @@ -69,4 +69,13 @@ export type { AccessTokenProvider, } from "../types"; export { DataStatus, IModelCellColumn, ITwinCellColumn } from "../types"; -export type { IModelTableOverridesMUI, ITwinTableOverridesMUI } from "./types"; +export type { + IModelTableOverridesMUI, + ITwinTableOverridesMUI, + IModelTableSortField, + IModelTableSortModel, + ITwinTableSortField, + ITwinTableSortModel, + TypedGridSortItem, + TypedGridSortModel, +} from "./types"; diff --git a/packages/modules/imodel-browser/src/mui/types.ts b/packages/modules/imodel-browser/src/mui/types.ts index d3811324..1bcb73be 100644 --- a/packages/modules/imodel-browser/src/mui/types.ts +++ b/packages/modules/imodel-browser/src/mui/types.ts @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { GridColDef } from "@mui/x-data-grid"; +import { GridColDef, GridSortItem } from "@mui/x-data-grid"; import { type IModelFull, @@ -12,6 +12,30 @@ import { ITwinCellColumn, } from "../types"; +/** A DataGrid sort item whose `field` is limited to a known set of column names. */ +export type TypedGridSortItem = GridSortItem & { + field: Field; +}; + +/** A DataGrid sort model whose items are limited to a known set of column names. */ +export type TypedGridSortModel = + TypedGridSortItem[]; + +/** Sortable column field names for the MUI iModel table. */ +export type IModelTableSortField = "name" | "lastChangesetPushDateTime"; + +/** Sort model for the MUI iModel table, limited to its sortable field names. */ +export type IModelTableSortModel = TypedGridSortModel; + +/** Sortable column field names for the MUI iTwin table. */ +export type ITwinTableSortField = + | "number" + | "displayName" + | "lastModifiedDateTime"; + +/** Sort model for the MUI iTwin table, limited to its sortable field names. */ +export type ITwinTableSortModel = TypedGridSortModel; + export type IModelTableOverridesMUI = { /** Per-column overrides merged onto the default column definitions. */ columnOverrides?: Partial< From 13484ef929d5de8249e13d379a19d4a1fbc65fa8 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 31 Jul 2026 11:17:26 -0400 Subject: [PATCH 07/22] fix: update TypedGridSortItem to use GridSortModel for improved type safety --- packages/modules/imodel-browser/src/mui/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/imodel-browser/src/mui/types.ts b/packages/modules/imodel-browser/src/mui/types.ts index 1bcb73be..5661d7d1 100644 --- a/packages/modules/imodel-browser/src/mui/types.ts +++ b/packages/modules/imodel-browser/src/mui/types.ts @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { GridColDef, GridSortItem } from "@mui/x-data-grid"; +import { GridColDef, GridSortModel } from "@mui/x-data-grid"; import { type IModelFull, @@ -13,7 +13,7 @@ import { } from "../types"; /** A DataGrid sort item whose `field` is limited to a known set of column names. */ -export type TypedGridSortItem = GridSortItem & { +export type TypedGridSortItem = GridSortModel[number] & { field: Field; }; From 69f0806f6e9b6a443b3bbb50cc23b94a12eeb19c Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 31 Jul 2026 12:16:37 -0400 Subject: [PATCH 08/22] feat: add Jest virtual folders and update TypeScript configurations for improved testing setup --- .vscode/settings.json | 24 ++++++++++++++++++- .../modules/imodel-browser/jest.config.js | 1 + .../iModelGrid/useIModelSort.test.ts | 24 +++++++++---------- .../modules/imodel-browser/src/tsconfig.json | 9 +++++++ .../modules/imodel-browser/tsconfig.test.json | 2 +- tsconfig.json | 3 ++- 6 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 packages/modules/imodel-browser/src/tsconfig.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 3ef6b860..4e4a11a8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,27 @@ "search.exclude": { "**/CHANGELOG.md": true }, - "js/ts.preferences.autoImportSpecifierExcludeRegexes": ["^@mui/[^/]+$"] + "js/ts.preferences.autoImportSpecifierExcludeRegexes": ["^@mui/[^/]+$"], + "jest.virtualFolders": [ + { + "name": "create-imodel", + "rootPath": "packages/modules/create-imodel" + }, + { + "name": "delete-imodel", + "rootPath": "packages/modules/delete-imodel" + }, + { + "name": "delete-itwin", + "rootPath": "packages/modules/delete-itwin" + }, + { + "name": "imodel-browser", + "rootPath": "packages/modules/imodel-browser" + }, + { + "name": "manage-versions", + "rootPath": "packages/modules/manage-versions" + } + ] } diff --git a/packages/modules/imodel-browser/jest.config.js b/packages/modules/imodel-browser/jest.config.js index e5d37251..084aeecc 100644 --- a/packages/modules/imodel-browser/jest.config.js +++ b/packages/modules/imodel-browser/jest.config.js @@ -14,6 +14,7 @@ module.exports = { ], }, transformIgnorePatterns: [ + "/scripts/setupJest\\.js$", "node_modules/(?!(\\.pnpm/(@stratakit|@ariakit|@mui).*|@bentley/ui|@stratakit|@ariakit|@mui))", "^.+\\.module\\.(css|sass|scss)$", ], diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts index 12198583..cc388795 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts @@ -8,12 +8,12 @@ import { IModelFull, IModelSortOptionsKeys } from "../../types"; import { useIModelSort } from "./useIModelSort"; describe("useIModelSort hook", () => { - it.each(["name", "createdDateTime"] as IModelSortOptionsKeys[])( + it.each(["name", "lastChangesetPushDateTime"] as IModelSortOptionsKeys[])( "sorts correctly with %s", (sortType) => { const expectedSortOrder = { name: ["3", "4", "1", "2", "5"], - createdDateTime: ["4", "5", "2", "3", "1"], + lastChangesetPushDateTime: ["4", "5", "2", "3", "1"], }[sortType]; const iModels: IModelFull[] = [ { @@ -22,7 +22,7 @@ describe("useIModelSort hook", () => { name: "c", description: "e", state: "initialized", - createdDateTime: "2020-09-05T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-05T12:42:51.593Z", }, { id: "2", @@ -30,7 +30,7 @@ describe("useIModelSort hook", () => { name: "d", description: "d", state: "initialized", - createdDateTime: "2020-09-03T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-03T12:42:51.593Z", }, { id: "3", @@ -38,7 +38,7 @@ describe("useIModelSort hook", () => { name: "a", description: "c", state: "notInitialized", - createdDateTime: "2020-09-04T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-04T12:42:51.593Z", }, { id: "4", @@ -46,7 +46,7 @@ describe("useIModelSort hook", () => { name: "b", description: "b", state: "notInitialized", - createdDateTime: "2020-09-01T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-01T12:42:51.593Z", }, { id: "5", @@ -54,7 +54,7 @@ describe("useIModelSort hook", () => { name: "d", description: "a", state: "initialized", - createdDateTime: "2020-09-02T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-02T12:42:51.593Z", }, ]; const { result, rerender } = renderHook( @@ -82,7 +82,7 @@ describe("useIModelSort hook", () => { name: "c", description: "e", state: "initialized", - createdDateTime: "2020-09-05T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-05T12:42:51.593Z", }, { id: "2", @@ -90,7 +90,7 @@ describe("useIModelSort hook", () => { name: "d", description: "d", state: "initialized", - createdDateTime: "2020-09-03T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-03T12:42:51.593Z", }, { id: "3", @@ -98,7 +98,7 @@ describe("useIModelSort hook", () => { name: "a", description: "c", state: "notInitialized", - createdDateTime: "2020-09-04T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-04T12:42:51.593Z", }, { id: "4", @@ -106,7 +106,7 @@ describe("useIModelSort hook", () => { name: "b", description: "b", state: "notInitialized", - createdDateTime: "2020-09-01T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-01T12:42:51.593Z", }, { id: "5", @@ -114,7 +114,7 @@ describe("useIModelSort hook", () => { name: "d", description: "a", state: "initialized", - createdDateTime: "2020-09-02T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-02T12:42:51.593Z", }, ]; const { result } = renderHook(() => diff --git a/packages/modules/imodel-browser/src/tsconfig.json b/packages/modules/imodel-browser/src/tsconfig.json new file mode 100644 index 00000000..dd4d57b0 --- /dev/null +++ b/packages/modules/imodel-browser/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.test.json", + "compilerOptions": { + "noEmit": true, + "types": ["jest"], + "module": "node16" + }, + "include": ["./**/*.test.ts", "./**/*.test.tsx", "./**/*.d.ts"] +} \ No newline at end of file diff --git a/packages/modules/imodel-browser/tsconfig.test.json b/packages/modules/imodel-browser/tsconfig.test.json index 5627b96b..466eb0a9 100644 --- a/packages/modules/imodel-browser/tsconfig.test.json +++ b/packages/modules/imodel-browser/tsconfig.test.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "module": "CommonJS", - "moduleResolution": "Node10", + "moduleResolution": "Node16", "types": ["jest", "node"], "rootDir": "./src", "outDir": "./esm" diff --git a/tsconfig.json b/tsconfig.json index ccfb5c64..00a5e7f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "outDir": "./lib" + "outDir": "./lib", + "rootDir": "." }, "include": ["**/*.ts*", "**/*.js"] } From 72a569d4941a5cf08c0f0cbeb9bae42d45fb12d7 Mon Sep 17 00:00:00 2001 From: Omar H Date: Fri, 31 Jul 2026 14:16:45 -0400 Subject: [PATCH 09/22] Update omar-save-sort-imodel-table_2026-07-10-18-00.json Co-authored-by: Alex Dunae --- .../omar-save-sort-imodel-table_2026-07-10-18-00.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json b/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json index 2fe0b56f..1098306f 100644 --- a/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json +++ b/common/changes/@itwin/imodel-browser-react/omar-save-sort-imodel-table_2026-07-10-18-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@itwin/imodel-browser-react", - "comment": "Controlled table sorting for iTwin/iModel tables", + "comment": "Controlled table sorting for iTwin/iModel grids", "type": "minor" } ], From 367c765ea624a0956a61071715ffbc43fb634550 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Aug 2026 12:56:06 -0400 Subject: [PATCH 10/22] feat: enhance sorting functionality in ITwinGrid and IModelGrid components with updated view mode and action logging --- .../src/imodel-browser/mui/IModelGridMUI.stories.tsx | 6 +++++- .../src/imodel-browser/mui/ITwinGridMUI.stories.tsx | 12 ++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index e778dcf5..46499a0a 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -211,6 +211,7 @@ const TableWithControlledSortRender = (args: IModelGridMUIProps) => { {...args} sortOptions={sortModel} onSortModelChange={(newSortModel: IModelTableSortModel) => { + action("sort model changed")(newSortModel); if (newSortModel.length > 0) { const newSort = newSortModel[0]; setSortModel({ @@ -226,7 +227,10 @@ const TableWithControlledSortRender = (args: IModelGridMUIProps) => { export const TableWithControlledSort: StoryObj = { render: (args) => , - args: { ...baseArgs }, + args: { + ...baseArgs, + viewMode: "cells", + }, parameters: { docs: { description: { diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 87bce438..267d7926 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -462,24 +462,16 @@ const TableWithControlledSortRender = (args: ITwinGridProps) => { export const TableWithControlledSort: StoryObj = { render: (args) => , - args: { ...baseArgs }, + args: { ...baseArgs, viewMode: "cells" }, parameters: { docs: { description: { story: - "The sort state is fully controlled by the parent via `sortModel` and `onSortModelChange`, so it can be saved anywhere the consumer wants.", + "The initial table sort is derived from the `orderbyOptions` prop, and changes are reported via `onSortModelChange` so the sort state can be saved anywhere the consumer wants.", }, }, }, }; -// TableWithControlledSort.parameters = { -// docs: { -// description: { -// story: -// "The initial table sort is derived from the `orderbyOptions` prop, and changes are reported via `onSortModelChange` so the sort state can be saved anywhere the consumer wants.", -// }, -// }, -// }; export default { title: "imodel-browser/ITwinGridMUI", From bb84e3c21d552f61e6bbe3eac9bc8285c5925d53 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Aug 2026 13:02:50 -0400 Subject: [PATCH 11/22] feat: add sorting by created date to IModelGrid components and update related types --- .../imodel-browser/mui/IModelGridMUI.stories.tsx | 13 +++++++++++++ .../src/containers/iModelGrid/useIModelSort.test.ts | 1 + .../mui/containers/iModelGrid/IModelTableMUI.tsx | 10 ++++++++++ packages/modules/imodel-browser/src/mui/types.ts | 7 +++++-- packages/modules/imodel-browser/src/types.ts | 5 ++++- 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index 46499a0a..b154dec6 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -195,6 +195,19 @@ const TableWithControlledSortRender = (args: IModelGridMUIProps) => { })) } /> + + setSortModel((prev) => ({ + ...prev, + sortType: "createdDateTime", + })) + } + /> { (sortType) => { const expectedSortOrder = { name: ["3", "4", "1", "2", "5"], + createdDateTime: ["4", "5", "2", "3", "1"], lastChangesetPushDateTime: ["4", "5", "2", "3", "1"], }[sortType]; const iModels: IModelFull[] = [ diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx index a1215365..a9fa1d02 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx @@ -160,6 +160,15 @@ export const IModelTableMUI = ({ disableColumnMenu: true, ...columnOverrides[IModelCellColumn.LastModified], }, + // Hidden column so the sort model can sort by creation date even though + // the column is never displayed. + { + field: "createdDateTime", + headerName: "", + valueGetter: (_value: string | undefined, row: IModelFull) => + row.createdDateTime ?? "", + disableColumnMenu: true, + }, !hideColumns.includes(IModelCellColumn.Options) && { field: "actions", headerName: "", @@ -202,6 +211,7 @@ export const IModelTableMUI = ({ rows={iModels} columns={columns} + columnVisibilityModel={{ createdDateTime: false }} nonce={nonce} loading={isLoading} sortModel={sortModel} diff --git a/packages/modules/imodel-browser/src/mui/types.ts b/packages/modules/imodel-browser/src/mui/types.ts index 5661d7d1..9b0ab44a 100644 --- a/packages/modules/imodel-browser/src/mui/types.ts +++ b/packages/modules/imodel-browser/src/mui/types.ts @@ -21,8 +21,11 @@ export type TypedGridSortItem = GridSortModel[number] & { export type TypedGridSortModel = TypedGridSortItem[]; -/** Sortable column field names for the MUI iModel table. */ -export type IModelTableSortField = "name" | "lastChangesetPushDateTime"; +/** Sortable column field names for the MUI iModel table. `createdDateTime` is sortable even though it is not displayed as a column. */ +export type IModelTableSortField = + | "name" + | "lastChangesetPushDateTime" + | "createdDateTime"; /** Sort model for the MUI iModel table, limited to its sortable field names. */ export type IModelTableSortModel = TypedGridSortModel; diff --git a/packages/modules/imodel-browser/src/types.ts b/packages/modules/imodel-browser/src/types.ts index 5b4bc35c..337a984d 100644 --- a/packages/modules/imodel-browser/src/types.ts +++ b/packages/modules/imodel-browser/src/types.ts @@ -97,7 +97,10 @@ export type DataMode = "internal" | "external"; type SortOptions = { sortType: K; descending: boolean }; /** Supported IModel sorting types */ -export type IModelSortOptionsKeys = "name" | "lastChangesetPushDateTime"; +export type IModelSortOptionsKeys = + | "name" + | "createdDateTime" + | "lastChangesetPushDateTime"; /** Object/function that configure IModel sorting behavior. */ export type IModelSortOptions = SortOptions; From 8742b39227a4750970a4efde484458d3f902bd75 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Aug 2026 13:10:45 -0400 Subject: [PATCH 12/22] revert changes --- .../src/imodel-browser/mui/ITwinGridMUI.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 267d7926..8175d080 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -12,6 +12,7 @@ import { ITwinTableSortModel, ITwinTile, } from "@itwin/imodel-browser-react/mui"; +import { SvgHeart } from "@itwin/itwinui-icons-react"; import { Code, IconButton } from "@itwin/itwinui-react"; import Avatar from "@mui/material/Avatar"; import AvatarGroup from "@mui/material/AvatarGroup"; @@ -97,7 +98,9 @@ export const TableViewWithOverrides: StoryObj = { e.stopPropagation(); action("Icon Clicked")(); }} - >{" "} + > + + {" "} {params.formattedValue} ), From 98a338ffaf66d1834b4d48c4b95af23ed1a114a8 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Aug 2026 13:18:18 -0400 Subject: [PATCH 13/22] feat: add support for sorting by createdDateTime in useIModelSort hook and tests --- .../iModelGrid/useIModelSort.test.ts | 134 +++++++++--------- .../containers/iModelGrid/useIModelSort.ts | 1 + 2 files changed, 71 insertions(+), 64 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts index a6e76d9c..87d8b6b3 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.test.ts @@ -8,71 +8,77 @@ import { IModelFull, IModelSortOptionsKeys } from "../../types"; import { useIModelSort } from "./useIModelSort"; describe("useIModelSort hook", () => { - it.each(["name", "lastChangesetPushDateTime"] as IModelSortOptionsKeys[])( - "sorts correctly with %s", - (sortType) => { - const expectedSortOrder = { - name: ["3", "4", "1", "2", "5"], - createdDateTime: ["4", "5", "2", "3", "1"], - lastChangesetPushDateTime: ["4", "5", "2", "3", "1"], - }[sortType]; - const iModels: IModelFull[] = [ - { - id: "1", - displayName: "d", - name: "c", - description: "e", - state: "initialized", - lastChangesetPushDateTime: "2020-09-05T12:42:51.593Z", - }, - { - id: "2", - displayName: "a", - name: "d", - description: "d", - state: "initialized", - lastChangesetPushDateTime: "2020-09-03T12:42:51.593Z", - }, - { - id: "3", - displayName: "e", - name: "a", - description: "c", - state: "notInitialized", - lastChangesetPushDateTime: "2020-09-04T12:42:51.593Z", - }, - { - id: "4", - displayName: "b", - name: "b", - description: "b", - state: "notInitialized", - lastChangesetPushDateTime: "2020-09-01T12:42:51.593Z", - }, - { - id: "5", - displayName: "c", - name: "d", - description: "a", - state: "initialized", - lastChangesetPushDateTime: "2020-09-02T12:42:51.593Z", - }, - ]; - const { result, rerender } = renderHook( - (props: { descending: boolean }) => - useIModelSort(iModels, { sortType, descending: props.descending }), - { initialProps: { descending: false } } - ); - expect(result.current.map((iModel) => iModel.id)).toEqual( - expectedSortOrder - ); + it.each([ + "name", + "createdDateTime", + "lastChangesetPushDateTime", + ] as IModelSortOptionsKeys[])("sorts correctly with %s", (sortType) => { + const expectedSortOrder = { + name: ["3", "4", "1", "2", "5"], + createdDateTime: ["2", "3", "1", "4", "5"], + lastChangesetPushDateTime: ["4", "5", "2", "3", "1"], + }[sortType]; + const iModels: IModelFull[] = [ + { + id: "1", + displayName: "d", + name: "c", + description: "e", + state: "initialized", + createdDateTime: "2020-08-03T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-05T12:42:51.593Z", + }, + { + id: "2", + displayName: "a", + name: "d", + description: "d", + state: "initialized", + createdDateTime: "2020-08-01T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-03T12:42:51.593Z", + }, + { + id: "3", + displayName: "e", + name: "a", + description: "c", + state: "notInitialized", + createdDateTime: "2020-08-02T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-04T12:42:51.593Z", + }, + { + id: "4", + displayName: "b", + name: "b", + description: "b", + state: "notInitialized", + createdDateTime: "2020-08-04T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-01T12:42:51.593Z", + }, + { + id: "5", + displayName: "c", + name: "d", + description: "a", + state: "initialized", + createdDateTime: "2020-08-05T12:42:51.593Z", + lastChangesetPushDateTime: "2020-09-02T12:42:51.593Z", + }, + ]; + const { result, rerender } = renderHook( + (props: { descending: boolean }) => + useIModelSort(iModels, { sortType, descending: props.descending }), + { initialProps: { descending: false } } + ); + expect(result.current.map((iModel) => iModel.id)).toEqual( + expectedSortOrder + ); - rerender({ descending: true }); - expect(result.current.map((iModel) => iModel.id)).toEqual( - expectedSortOrder.reverse() - ); - } - ); + rerender({ descending: true }); + expect(result.current.map((iModel) => iModel.id)).toEqual( + expectedSortOrder.reverse() + ); + }); it("do not modify input array", () => { const expectedSortOrder = ["1", "2", "3", "4", "5"]; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index c72e6455..9417dde3 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -21,6 +21,7 @@ function isSupportedSortType( "name", "description", "initialized", + "createdDateTime", "lastChangesetPushDateTime", ].includes(sortType) ); From d4f134eb98a32133c1e10f2e720e71368823651d Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Aug 2026 13:44:38 -0400 Subject: [PATCH 14/22] feat: update sorting functionality in IModelGrid and ITwinGrid components with new sort options handling --- .../mui/IModelGridMUI.stories.tsx | 35 ++++++++----------- .../mui/ITwinGridMUI.stories.tsx | 12 +++---- .../mui/containers/ITwinGrid/ITwinGridMUI.tsx | 20 +++++++---- .../containers/iModelGrid/IModelGridMUI.tsx | 22 ++++++++---- .../modules/imodel-browser/src/mui/index.ts | 11 +----- 5 files changed, 50 insertions(+), 50 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index b154dec6..93041b75 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -5,7 +5,6 @@ import { type IModelFull, type IModelGridProps as IModelGridMUIProps, - type IModelTableSortModel, DataStatus, IModelCellColumn, IModelGrid as ExternalComponent, @@ -156,7 +155,7 @@ export const TableViewWithOverrides: StoryObj = { }; const TableWithControlledSortRender = (args: IModelGridMUIProps) => { - const [sortModel, setSortModel] = React.useState({ + const [sortOptions, setSortOptions] = React.useState({ sortType: "name", descending: true, }); @@ -175,21 +174,21 @@ const TableWithControlledSortRender = (args: IModelGridMUIProps) => { - setSortModel((prev) => ({ ...prev, sortType: "name" })) + setSortOptions((prev) => ({ ...prev, sortType: "name" })) } /> - setSortModel((prev) => ({ + setSortOptions((prev) => ({ ...prev, sortType: "lastChangesetPushDateTime", })) @@ -199,21 +198,21 @@ const TableWithControlledSortRender = (args: IModelGridMUIProps) => { label="Created Date" clickable variant={ - sortModel.sortType === "createdDateTime" ? "filled" : "outlined" + sortOptions.sortType === "createdDateTime" ? "filled" : "outlined" } onClick={() => - setSortModel((prev) => ({ + setSortOptions((prev) => ({ ...prev, sortType: "createdDateTime", })) } /> - setSortModel((prev) => ({ + setSortOptions((prev) => ({ ...prev, descending: !prev.descending, })) @@ -222,16 +221,10 @@ const TableWithControlledSortRender = (args: IModelGridMUIProps) => { { - action("sort model changed")(newSortModel); - if (newSortModel.length > 0) { - const newSort = newSortModel[0]; - setSortModel({ - sortType: newSort.field, - descending: newSort.sort === "desc", - }); - } + sortOptions={sortOptions} + onSortOptionsChange={(newSortOptions) => { + action("sort options changed")(newSortOptions); + setSortOptions(newSortOptions); }} /> @@ -248,7 +241,7 @@ export const TableWithControlledSort: StoryObj = { docs: { description: { story: - "The sort state is fully controlled by the parent via `sortModel` and `onSortModelChange`, so it can be saved anywhere the consumer wants.", + "The sort state is fully controlled by the parent via `sortOptions` and `onSortOptionsChange`, so it can be saved anywhere the consumer wants.", }, }, }, diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 8175d080..6e7a667a 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -9,7 +9,6 @@ import { DataStatus, ITwinCellColumn, ITwinGrid as ExternalComponent, - ITwinTableSortModel, ITwinTile, } from "@itwin/imodel-browser-react/mui"; import { SvgHeart } from "@itwin/itwinui-icons-react"; @@ -451,11 +450,10 @@ const TableWithControlledSortRender = (args: ITwinGridProps) => { { - action("sort model changed")(newSortModel); - if (newSortModel.length > 0) { - const newSort = newSortModel[0]; - setOrderbyOptions(`${newSort.field} ${newSort.sort ?? "asc"}`); + onOrderbyOptionsChange={(newOrderbyOptions) => { + action("orderby options changed")(newOrderbyOptions); + if (newOrderbyOptions) { + setOrderbyOptions(newOrderbyOptions); } }} /> @@ -470,7 +468,7 @@ export const TableWithControlledSort: StoryObj = { docs: { description: { story: - "The initial table sort is derived from the `orderbyOptions` prop, and changes are reported via `onSortModelChange` so the sort state can be saved anywhere the consumer wants.", + "The sort state is fully controlled by the parent via `orderbyOptions` and `onOrderbyOptionsChange`, so it can be saved anywhere the consumer wants.", }, }, }, diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index 949f8800..26c19eb5 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -85,8 +85,13 @@ export interface ITwinGridPropsMUI nonce?: string; /** Localized string overrides - falls back to default English strings if not provided */ stringsOverrides?: Partial; - /** Called whenever the table sort model changes. */ - onSortModelChange?: (sortModel: ITwinTableSortModel) => void; + /** + * Called when the user changes the table sort (e.g. by clicking a column + * header). Receives the new sort in the same shape as the `orderbyOptions` + * prop (e.g. `displayName asc`), so it can be stored and passed back as-is. + * Receives `undefined` when the sort is cleared. + */ + onOrderbyOptionsChange?: (orderbyOptions: string | undefined) => void; } /** @@ -117,7 +122,7 @@ const ITwinGridMUIInternal = ({ viewMode, tableOverrides, className, - onSortModelChange, + onOrderbyOptionsChange, nonce, }: ITwinGridPropsMUI) => { const logger = useLogger(); @@ -154,7 +159,7 @@ const ITwinGridMUIInternal = ({ // Own the sort state so column-header clicks re-sort the table even when the // consumer does not control it, while staying in sync with the prop-derived - // sort and forwarding changes through `onSortModelChange`. + // sort and forwarding changes through `onOrderbyOptionsChange`. const [tableSortModel, setTableSortModel] = React.useState< ITwinTableSortModel | undefined >(initialTableSortModel); @@ -164,9 +169,12 @@ const ITwinGridMUIInternal = ({ const handleSortModelChange = React.useCallback( (model: ITwinTableSortModel) => { setTableSortModel(model); - onSortModelChange?.(model); + const item = model[0]; + onOrderbyOptionsChange?.( + item ? `${item.field} ${item.sort ?? "asc"}` : undefined + ); }, - [onSortModelChange] + [onOrderbyOptionsChange] ); const strings = React.useMemo( diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx index 4531dfb9..21b0ccf1 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx @@ -115,8 +115,12 @@ export interface IModelGridMUIProps */ nonce?: string; stringsOverrides?: Partial; - /** Called whenever the table sort model changes. */ - onSortModelChange?: (sortModel: IModelTableSortModel) => void; + /** + * Called when the user changes the table sort (e.g. by clicking a column + * header). Receives the new sort in the same shape as the `sortOptions` + * prop, so it can be stored and passed back as-is. + */ + onSortOptionsChange?: (sortOptions: IModelSortOptions) => void; } /** @@ -165,7 +169,7 @@ const IModelGridInternal = ({ onRefetch, dataMode = "internal", disableAddToRecents = false, - onSortModelChange, + onSortOptionsChange, nonce, }: IModelGridMUIProps) => { const logger = useLogger(); @@ -196,7 +200,7 @@ const IModelGridInternal = ({ // Own the sort state so column-header clicks re-sort the table even when the // consumer does not control it, while staying in sync with the prop-derived - // sort and forwarding changes through `onSortModelChange`. + // sort and forwarding changes through `onSortOptionsChange`. const [tableSortModel, setTableSortModel] = React.useState(initialTableSortModel); React.useEffect(() => { @@ -205,9 +209,15 @@ const IModelGridInternal = ({ const handleSortModelChange = React.useCallback( (model: IModelTableSortModel) => { setTableSortModel(model); - onSortModelChange?.(model); + const item = model[0]; + if (item) { + onSortOptionsChange?.({ + sortType: item.field, + descending: item.sort === "desc", + }); + } }, - [onSortModelChange] + [onSortOptionsChange] ); const strings = React.useMemo( diff --git a/packages/modules/imodel-browser/src/mui/index.ts b/packages/modules/imodel-browser/src/mui/index.ts index f49f40fa..0402e731 100644 --- a/packages/modules/imodel-browser/src/mui/index.ts +++ b/packages/modules/imodel-browser/src/mui/index.ts @@ -69,13 +69,4 @@ export type { AccessTokenProvider, } from "../types"; export { DataStatus, IModelCellColumn, ITwinCellColumn } from "../types"; -export type { - IModelTableOverridesMUI, - ITwinTableOverridesMUI, - IModelTableSortField, - IModelTableSortModel, - ITwinTableSortField, - ITwinTableSortModel, - TypedGridSortItem, - TypedGridSortModel, -} from "./types"; +export type { IModelTableOverridesMUI, ITwinTableOverridesMUI } from "./types"; From 01e1d566439d53a1392dfd707b035d135d844acd Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Wed, 5 Aug 2026 14:46:52 -0400 Subject: [PATCH 15/22] feat: enhance sorting functionality in IModelGrid and ITwinGrid components with controlled sort options --- .../mui/IModelGridMUI.stories.tsx | 2 +- .../mui/ITwinGridMUI.stories.tsx | 143 +++++++++--------- .../mui/containers/ITwinGrid/ITwinGridMUI.tsx | 22 +-- .../containers/ITwinGrid/ITwinTableMUI.tsx | 8 +- .../containers/iModelGrid/IModelGridMUI.tsx | 52 +------ .../containers/iModelGrid/IModelTableMUI.tsx | 68 +++++++-- 6 files changed, 146 insertions(+), 149 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index 93041b75..09302c41 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -70,7 +70,6 @@ export default { const baseArgs: IModelGridMUIProps = { apiOverrides: { serverEnvironmentPrefix: "qa" }, - sortOptions: { sortType: "name", descending: false }, actions: [ { key: "open", @@ -92,6 +91,7 @@ const baseArgs: IModelGridMUIProps = { onClick: (iModel) => action("Details for " + iModel?.displayName)(iModel), }, ], + onSortOptionsChange: undefined, }; export const Primary: StoryObj = { diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 6e7a667a..4927de71 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -44,6 +44,7 @@ const baseArgs: ITwinGridProps = { onClick: (iTwin) => action("Open " + iTwin.displayName)(iTwin), }, ], + onOrderbyOptionsChange: undefined, }; export const Primary: StoryObj = { @@ -117,6 +118,77 @@ export const TableViewWithOverrides: StoryObj = { }, }; +const TableWithControlledSortRender = (args: ITwinGridProps) => { + const [orderbyOptions, setOrderbyOptions] = React.useState("number desc"); + const [field, direction] = orderbyOptions.split(/\s+/); + + return ( +
+
+ Sort by: + setOrderbyOptions(`number ${direction}`)} + /> + setOrderbyOptions(`displayName ${direction}`)} + /> + setOrderbyOptions(`lastModifiedDateTime ${direction}`)} + /> + + setOrderbyOptions( + `${field} ${direction === "desc" ? "asc" : "desc"}` + ) + } + /> +
+ { + action("orderby options changed")(newOrderbyOptions); + if (newOrderbyOptions) { + setOrderbyOptions(newOrderbyOptions); + } + }} + /> +
+ ); +}; + +export const TableWithControlledSort: StoryObj = { + render: (args) => , + args: { ...baseArgs, viewMode: "cells" }, + parameters: { + docs: { + description: { + story: + "The sort state is fully controlled by the parent via `orderbyOptions` and `onOrderbyOptionsChange`, so it can be saved anywhere the consumer wants.", + }, + }, + }, +}; + export const OverrideApiData: StoryObj = { args: { ...baseArgs, @@ -403,77 +475,6 @@ export const StringsOverrideTable: StoryObj = { }, }; -const TableWithControlledSortRender = (args: ITwinGridProps) => { - const [orderbyOptions, setOrderbyOptions] = React.useState("number desc"); - const [field, direction] = orderbyOptions.split(/\s+/); - - return ( -
-
- Sort by: - setOrderbyOptions(`number ${direction}`)} - /> - setOrderbyOptions(`displayName ${direction}`)} - /> - setOrderbyOptions(`lastModifiedDateTime ${direction}`)} - /> - - setOrderbyOptions( - `${field} ${direction === "desc" ? "asc" : "desc"}` - ) - } - /> -
- { - action("orderby options changed")(newOrderbyOptions); - if (newOrderbyOptions) { - setOrderbyOptions(newOrderbyOptions); - } - }} - /> -
- ); -}; - -export const TableWithControlledSort: StoryObj = { - render: (args) => , - args: { ...baseArgs, viewMode: "cells" }, - parameters: { - docs: { - description: { - story: - "The sort state is fully controlled by the parent via `orderbyOptions` and `onOrderbyOptionsChange`, so it can be saved anywhere the consumer wants.", - }, - }, - }, -}; - export default { title: "imodel-browser/ITwinGridMUI", component: ITwinGrid, diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index 26c19eb5..e194738a 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -137,11 +137,9 @@ const ITwinGridMUIInternal = ({ // Translate the `orderbyOptions` prop (an OData `$orderby` string such as // "number DESC" or "displayName ASC") into the equivalent DataGrid sort model // so the table view reflects the requested sort without reordering the fetched - // list (which keeps its default sort). Left undefined when no sort is - // requested so the table stays uncontrolled. - const initialTableSortModel = React.useMemo< - ITwinTableSortModel | undefined - >(() => { + // list (which keeps its default sort). The table is sort-controlled only when + // `onOrderbyOptionsChange` is provided. + const tableSortModel = React.useMemo(() => { if (!orderbyOptions) { return undefined; } @@ -157,18 +155,8 @@ const ITwinGridMUIInternal = ({ ]; }, [orderbyOptions]); - // Own the sort state so column-header clicks re-sort the table even when the - // consumer does not control it, while staying in sync with the prop-derived - // sort and forwarding changes through `onOrderbyOptionsChange`. - const [tableSortModel, setTableSortModel] = React.useState< - ITwinTableSortModel | undefined - >(initialTableSortModel); - React.useEffect(() => { - setTableSortModel(initialTableSortModel); - }, [initialTableSortModel]); const handleSortModelChange = React.useCallback( (model: ITwinTableSortModel) => { - setTableSortModel(model); const item = model[0]; onOrderbyOptionsChange?.( item ? `${item.field} ${item.sort ?? "asc"}` : undefined @@ -358,7 +346,9 @@ const ITwinGridMUIInternal = ({ isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} sortModel={tableSortModel} - onSortModelChange={handleSortModelChange} + onSortModelChange={ + onOrderbyOptionsChange ? handleSortModelChange : undefined + } nonce={nonce} /> ); diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx index 334336f4..038a468d 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx @@ -205,10 +205,12 @@ export const ITwinTableMUI = ({ nonce={nonce} loading={isLoading} sortModel={sortModel} - onSortModelChange={(model) => - onSortModelChange?.([...model] as ITwinTableSortModel) + onSortModelChange={ + onSortModelChange + ? (model) => onSortModelChange([...model] as ITwinTableSortModel) + : undefined } - sortingOrder={sortModel ? ["asc", "desc"] : ["asc", "desc", null]} + sortingOrder={onSortModelChange ? ["asc", "desc"] : ["asc", "desc", null]} onRowClick={ actions ? (params) => { diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx index 21b0ccf1..1cf619fe 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelGridMUI.tsx @@ -36,10 +36,7 @@ import { } from "../../../utils/iModelApi"; import { BaseCardLoading } from "../../components/baseCard/BaseCardLoading"; import { NoResultsMUI as NoResults } from "../../components/noResults/NoResultsMUI"; -import { - type IModelTableOverridesMUI, - type IModelTableSortModel, -} from "../../types"; +import { type IModelTableOverridesMUI } from "../../types"; import { stripNonTileProps } from "../../utils/stripNonTileProps"; import { type IModelTileMUIProps, @@ -152,7 +149,7 @@ const IModelGridInternal = ({ removeFromRecentsIcon, actions, iTwinId, - sortOptions = { sortType: "name", descending: false }, + sortOptions, requestType, stringsOverrides, tileOverrides, @@ -180,45 +177,10 @@ const IModelGridInternal = ({ descending: false, } : { - sortType: sortOptions.sortType, - descending: sortOptions.descending, + sortType: sortOptions?.sortType ?? "name", + descending: sortOptions?.descending ?? false, }; - }, [sortOptions.descending, sortOptions.sortType, viewMode]); - - // Translate the `sortOptions` prop into the equivalent DataGrid sort model so - // the table view reflects the requested sort without reordering the fetched - // list (which keeps its default sort). - const initialTableSortModel = React.useMemo( - () => [ - { - field: sortOptions.sortType, - sort: sortOptions.descending ? "desc" : "asc", - }, - ], - [sortOptions.sortType, sortOptions.descending] - ); - - // Own the sort state so column-header clicks re-sort the table even when the - // consumer does not control it, while staying in sync with the prop-derived - // sort and forwarding changes through `onSortOptionsChange`. - const [tableSortModel, setTableSortModel] = - React.useState(initialTableSortModel); - React.useEffect(() => { - setTableSortModel(initialTableSortModel); - }, [initialTableSortModel]); - const handleSortModelChange = React.useCallback( - (model: IModelTableSortModel) => { - setTableSortModel(model); - const item = model[0]; - if (item) { - onSortOptionsChange?.({ - sortType: item.field, - descending: item.sort === "desc", - }); - } - }, - [onSortOptionsChange] - ); + }, [sortOptions?.descending, sortOptions?.sortType, viewMode]); const strings = React.useMemo( () => @@ -470,8 +432,8 @@ const IModelGridInternal = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} - onSortModelChange={handleSortModelChange} - sortModel={tableSortModel} + sortOptions={sortOptions} + onSortOptionsChange={onSortOptionsChange} nonce={nonce} data-testid="imodel-table" /> diff --git a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx index a9fa1d02..07b288a7 100644 --- a/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/iModelGrid/IModelTableMUI.tsx @@ -12,7 +12,11 @@ import { Icon } from "@stratakit/mui"; import React from "react"; import { useIModelFavoritesContext } from "../../../contexts/IModelFavoritesContext"; -import { type IModelFull, IModelCellColumn } from "../../../types"; +import { + type IModelFull, + type IModelSortOptions, + IModelCellColumn, +} from "../../../types"; import { type MoreActionsMenuItemMUI, type ResolvedCardActionItem, @@ -66,12 +70,17 @@ export interface IModelTableMUIProps { /** Called when more data should be loaded. */ fetchMore?: (() => void) | false; /** - * Controlled sort model. When provided, the table's sort state is fully - * controlled by the parent and must be kept in sync via `onSortModelChange`. + * Requested sort. When `onSortOptionsChange` is provided the sort is fully + * controlled: store the reported value and pass it back through this prop. + * Otherwise this is only the initial sort and the table manages its own. + */ + sortOptions?: IModelSortOptions; + /** + * Called when the user changes the table sort (e.g. by clicking a column + * header). Providing this makes the sort controlled; receive the new sort + * in the same shape as the `sortOptions` prop and pass it back as-is. */ - sortModel?: IModelTableSortModel; - /** Called whenever the sort model changes. */ - onSortModelChange?: (sortModel: IModelTableSortModel) => void; + onSortOptionsChange?: (sortOptions: IModelSortOptions) => void; /** Nonce applied to `