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/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..1098306f --- /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 grids", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} \ No newline at end of file diff --git a/packages/apps/storybook/.storybook/itwinTheme.js b/packages/apps/storybook/.storybook/itwinTheme.js index cd864b05..6006ecaf 100644 --- a/packages/apps/storybook/.storybook/itwinTheme.js +++ b/packages/apps/storybook/.storybook/itwinTheme.js @@ -8,13 +8,13 @@ export const lightTheme = create({ base: "light", brandTitle: "iTwin Admin Components", - colorPrimary: '#0071B8', // --iui-color-background-accent - colorSecondary: '#0071B8', // --iui-color-background-accent + colorPrimary: "#0071B8", // --iui-color-background-accent + colorSecondary: "#0071B8", // --iui-color-background-accent // UI - appBg: '#FFFFFF', // --iui-color-background - appContentBg: '#FFFFFF', // --iui-color-background - appBorderColor: '#b3bcc1', // --iui-color-border + appBg: "#FFFFFF", // --iui-color-background + appContentBg: "#FFFFFF", // --iui-color-background + appBorderColor: "#b3bcc1", // --iui-color-border appBorderRadius: 9, // Typography @@ -24,17 +24,17 @@ export const lightTheme = create({ '"Noto Sans Mono", ui-monospace, "SF Mono", SFMono-Regular, "DejaVu Sans Mono", Menlo, Consolas, monospace', // Text colors - textColor: '#242424', //--iui-color-text + textColor: "#242424", //--iui-color-text // Toolbar default and active colors - barTextColor: '#242424', //--iui-color-text - barSelectedColor: '#0071B8', // --iui-color-background-accent - barBg: '#FFFFFF', // --iui-color-background + barTextColor: "#242424", //--iui-color-text + barSelectedColor: "#0071B8", // --iui-color-background-accent + barBg: "#FFFFFF", // --iui-color-background // Form colors - inputBg: '#FFFFFF', // --iui-color-background - inputBorder: '#b3bcc1', // --iui-color-border - inputTextColor: '#242424', //--iui-color-text + inputBg: "#FFFFFF", // --iui-color-background + inputBorder: "#b3bcc1", // --iui-color-border + inputTextColor: "#242424", //--iui-color-text inputBorderRadius: 3, }); @@ -42,13 +42,13 @@ export const darkTheme = create({ base: "dark", brandTitle: "iTwin Admin Components", - colorPrimary: '#0071B8', // --iui-color-background-accent - colorSecondary: '#94befa', // --iui-color-text-accent + colorPrimary: "#0071B8", // --iui-color-background-accent + colorSecondary: "#94befa", // --iui-color-text-accent // UI - appBg: '#333c41', // --iui-color-background - appContentBg: '#333c41', // --iui-color-background - appBorderColor: '#49555d', // --iui-color-border + appBg: "#333c41", // --iui-color-background + appContentBg: "#333c41", // --iui-color-background + appBorderColor: "#49555d", // --iui-color-border appBorderRadius: 9, // Typography @@ -58,16 +58,16 @@ export const darkTheme = create({ '"Noto Sans Mono", ui-monospace, "SF Mono", SFMono-Regular, "DejaVu Sans Mono", Menlo, Consolas, monospace', // Text colors - textColor: '#dbdbdb', //--iui-color-text + textColor: "#dbdbdb", //--iui-color-text // Toolbar default and active colors - barTextColor: '#dbdbdb', //--iui-color-text - barSelectedColor: '#94befa', // --iui-color-text-accent - barBg: '#333c41', // --iui-color-background + barTextColor: "#dbdbdb", //--iui-color-text + barSelectedColor: "#94befa", // --iui-color-text-accent + barBg: "#333c41", // --iui-color-background // Form colors - inputBg: '#333c41', // --iui-color-background - inputBorder: '#49555d', // --iui-color-border - inputTextColor: '#dbdbdb', //--iui-color-text - inputBorderRadius: 3 + inputBg: "#333c41", // --iui-color-background + inputBorder: "#49555d", // --iui-color-border + inputTextColor: "#dbdbdb", //--iui-color-text + inputBorderRadius: 3, }); diff --git a/packages/apps/storybook/src/addon/preset.js b/packages/apps/storybook/src/addon/preset.js index 68ef12e3..bd0dc317 100644 --- a/packages/apps/storybook/src/addon/preset.js +++ b/packages/apps/storybook/src/addon/preset.js @@ -1,3 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ module.exports = { managerEntries: [require.resolve("./manager.jsx")], }; diff --git a/packages/apps/storybook/src/create-imodel/CreateIModel.stories.tsx b/packages/apps/storybook/src/create-imodel/CreateIModel.stories.tsx index ec813ec8..e2df8482 100644 --- a/packages/apps/storybook/src/create-imodel/CreateIModel.stories.tsx +++ b/packages/apps/storybook/src/create-imodel/CreateIModel.stories.tsx @@ -14,6 +14,7 @@ import { import { LabeledInput, LabeledSelect } from "@itwin/itwinui-react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React from "react"; + import { iTwinAndAccessTokenArgTypes } from "../utils/storyHelp"; export default { diff --git a/packages/apps/storybook/src/create-imodel/UpdateIModel.stories.tsx b/packages/apps/storybook/src/create-imodel/UpdateIModel.stories.tsx index af055813..0a729cf6 100644 --- a/packages/apps/storybook/src/create-imodel/UpdateIModel.stories.tsx +++ b/packages/apps/storybook/src/create-imodel/UpdateIModel.stories.tsx @@ -5,6 +5,7 @@ import { UpdateIModel } from "@itwin/create-imodel-react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React from "react"; + import { accessTokenArgTypes } from "../utils/storyHelp"; function base64ToArrayBuffer(base64: string) { diff --git a/packages/apps/storybook/src/delete-imodel/DeleteIModel.stories.tsx b/packages/apps/storybook/src/delete-imodel/DeleteIModel.stories.tsx index 9d3a165d..f6cab36b 100644 --- a/packages/apps/storybook/src/delete-imodel/DeleteIModel.stories.tsx +++ b/packages/apps/storybook/src/delete-imodel/DeleteIModel.stories.tsx @@ -6,6 +6,7 @@ import { DeleteIModel, DeleteIModelProps } from "@itwin/delete-imodel-react"; import { Button } from "@itwin/itwinui-react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React, { useState } from "react"; + import { accessTokenArgTypes } from "../utils/storyHelp"; export default { diff --git a/packages/apps/storybook/src/delete-itwin/DeleteITwin.stories.tsx b/packages/apps/storybook/src/delete-itwin/DeleteITwin.stories.tsx index 53577800..233ccfc8 100644 --- a/packages/apps/storybook/src/delete-itwin/DeleteITwin.stories.tsx +++ b/packages/apps/storybook/src/delete-itwin/DeleteITwin.stories.tsx @@ -6,6 +6,7 @@ import { DeleteITwin, DeleteITwinProps } from "@itwin/delete-itwin-react"; import { Button } from "@itwin/itwinui-react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React, { useState } from "react"; + import { accessTokenArgTypes } from "../utils/storyHelp"; export default { diff --git a/packages/apps/storybook/src/examples/Authenticated.stories.tsx b/packages/apps/storybook/src/examples/Authenticated.stories.tsx index 0adb9eda..4e9f18ec 100644 --- a/packages/apps/storybook/src/examples/Authenticated.stories.tsx +++ b/packages/apps/storybook/src/examples/Authenticated.stories.tsx @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { Meta, StoryObj } from "@storybook/react-webpack5"; + import { accessTokenArgTypes } from "../utils/storyHelp"; import { Authentiated } from "./Authenticated"; diff --git a/packages/apps/storybook/src/examples/ITwinAwareComponent.stories.tsx b/packages/apps/storybook/src/examples/ITwinAwareComponent.stories.tsx index e4fc5749..c93ac7fd 100644 --- a/packages/apps/storybook/src/examples/ITwinAwareComponent.stories.tsx +++ b/packages/apps/storybook/src/examples/ITwinAwareComponent.stories.tsx @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { Meta, StoryObj } from "@storybook/react-webpack5"; + import { ITwinAwareComponent } from "./ITwinAwareComponent"; export default { diff --git a/packages/apps/storybook/src/imodel-browser/mui/BaseCard.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/BaseCard.stories.tsx index 5207a482..e255d7ef 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/BaseCard.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/BaseCard.stories.tsx @@ -7,7 +7,6 @@ import Avatar from "@mui/material/Avatar"; import AvatarGroup from "@mui/material/AvatarGroup"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; -import { action } from "storybook/actions"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import svgGeo from "@stratakit/icons/geospatial-features.svg"; import svgPin from "@stratakit/icons/pin.svg"; @@ -16,6 +15,7 @@ import svgStatusError from "@stratakit/icons/status-error.svg"; import svgStatusSuccess from "@stratakit/icons/status-success.svg"; import svgStatusWarning from "@stratakit/icons/status-warning.svg"; import React from "react"; +import { action } from "storybook/actions"; import { BaseCard, @@ -255,7 +255,11 @@ export const OpenSitePlus: StoryObj = { icon: svgGeo, }, { key: "share", label: "Share", onClick: action("menu: share clicked") }, - { key: "delete", label: "Delete", onClick: action("menu: delete clicked") }, + { + key: "delete", + label: "Delete", + onClick: action("menu: delete clicked"), + }, ], actions: [ { 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 7596d7bf..713e71f1 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 IModelSortOptionsMUI, DataStatus, IModelCellColumn, IModelGrid as ExternalComponent, @@ -69,7 +70,6 @@ export default { const baseArgs: IModelGridMUIProps = { apiOverrides: { serverEnvironmentPrefix: "qa" }, - sortOptions: { sortType: "name", descending: false }, actions: [ { key: "open", @@ -91,6 +91,7 @@ const baseArgs: IModelGridMUIProps = { onClick: (iModel) => action("Details for " + iModel?.displayName)(iModel), }, ], + onSortOptionsChange: undefined, }; export const Primary: StoryObj = { @@ -153,6 +154,91 @@ export const TableViewWithOverrides: StoryObj = { }, }; +const TableWithControlledSortRender = (args: IModelGridMUIProps) => { + const [sortOptions, setSortOptions] = React.useState< + IModelSortOptionsMUI | undefined + >({ field: "name", direction: "desc" }); + const field = sortOptions?.field; + const direction = sortOptions?.direction ?? "asc"; + + return ( +
+
+ Sort by: + setSortOptions({ field: "name", direction })} + /> + + setSortOptions({ field: "lastChangesetPushDateTime", direction }) + } + /> + + setSortOptions({ field: "createdDateTime", direction }) + } + /> + + setSortOptions( + (prev) => + prev && { + ...prev, + direction: prev.direction === "desc" ? "asc" : "desc", + } + ) + } + /> +
+ { + action("sort options changed")(newSortOptions); + setSortOptions(newSortOptions); + }} + /> +
+ ); +}; + +export const TableWithControlledSort: StoryObj = { + render: (args) => , + args: { + ...baseArgs, + viewMode: "cells", + }, + parameters: { + docs: { + description: { + story: + "The sort state is fully controlled by the parent via `sortOptions` and `onSortOptionsChange`, so it can be saved anywhere the consumer wants.", + }, + }, + }, +}; + const OverrideApiDataWithLoadMoreRender = (args: IModelGridMUIProps) => { const [data, setData] = React.useState(initialData); const [isLoading, setIsLoading] = React.useState(false); diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelTileMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelTileMUI.stories.tsx index bb8bedd5..42cdcce0 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelTileMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelTileMUI.stories.tsx @@ -11,12 +11,12 @@ import Avatar from "@mui/material/Avatar"; import AvatarGroup from "@mui/material/AvatarGroup"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; -import { action } from "storybook/actions"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import SvgDelete from "@stratakit/icons/delete.svg"; import svgRoad from "@stratakit/icons/road.svg"; import SvgShare from "@stratakit/icons/share.svg"; import React from "react"; +import { action } from "storybook/actions"; import bridgeThumbnail from "../../utils/bridge.jpg"; import overpassThumbnail from "../../utils/overpass.jpg"; 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 022b3272..6c129ac8 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 ITwinSortOptionsMUI, DataStatus, ITwinCellColumn, ITwinGrid as ExternalComponent, @@ -44,6 +45,7 @@ const baseArgs: ITwinGridProps = { onClick: (iTwin) => action("Open " + iTwin.displayName)(iTwin), }, ], + onSortOptionsChange: undefined, }; export const Primary: StoryObj = { @@ -117,6 +119,84 @@ export const TableViewWithOverrides: StoryObj = { }, }; +const TableWithControlledSortRender = (args: ITwinGridProps) => { + const [sortOptions, setSortOptions] = React.useState< + ITwinSortOptionsMUI | undefined + >({ field: "number", direction: "desc" }); + const field = sortOptions?.field; + const direction = sortOptions?.direction ?? "asc"; + + return ( +
+
+ Sort by: + setSortOptions({ field: "number", direction })} + /> + setSortOptions({ field: "displayName", direction })} + /> + + setSortOptions({ field: "lastModifiedDateTime", direction }) + } + /> + + setSortOptions( + (prev) => + prev && { + ...prev, + direction: prev.direction === "desc" ? "asc" : "desc", + } + ) + } + /> +
+ { + action("sort options changed")(newSortOptions); + setSortOptions(newSortOptions); + }} + /> +
+ ); +}; + +export const TableWithControlledSort: StoryObj = { + render: (args) => , + args: { ...baseArgs, viewMode: "cells" }, + parameters: { + docs: { + description: { + story: + "The sort state is fully controlled by the parent via `sortOptions` and `onSortOptionsChange`, so it can be saved anywhere the consumer wants.", + }, + }, + }, +}; + export const OverrideApiData: StoryObj = { args: { ...baseArgs, diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinTileMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinTileMUI.stories.tsx index dba2defc..0ef4c0a9 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinTileMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinTileMUI.stories.tsx @@ -10,10 +10,10 @@ import { import { SvgThumbnail } from "@itwin/imodel-browser-react/mui"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; -import { action } from "storybook/actions"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import svgMagnet from "@stratakit/icons/magnet.svg"; import React from "react"; +import { action } from "storybook/actions"; import { DefaultThumbnail } from "../../../../../modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTileMUI"; import bridgeThumbnail from "../../utils/bridge.jpg"; diff --git a/packages/apps/storybook/src/manage-versions/ManageVersions.stories.tsx b/packages/apps/storybook/src/manage-versions/ManageVersions.stories.tsx index f26e77ac..3769a5d1 100644 --- a/packages/apps/storybook/src/manage-versions/ManageVersions.stories.tsx +++ b/packages/apps/storybook/src/manage-versions/ManageVersions.stories.tsx @@ -7,9 +7,10 @@ import { ManageVersions as ExternalComponent, ManageVersionsProps, } from "@itwin/manage-versions-react"; -import { action } from "storybook/actions"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React from "react"; +import { action } from "storybook/actions"; + import { accessTokenArgTypes } from "../utils/storyHelp"; export const ManageVersions = (props: ManageVersionsProps) => ( 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..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,70 +8,77 @@ import { IModelFull, IModelSortOptionsKeys } from "../../types"; import { useIModelSort } from "./useIModelSort"; describe("useIModelSort hook", () => { - it.each(["name", "createdDateTime"] as IModelSortOptionsKeys[])( - "sorts correctly with %s", - (sortType) => { - const expectedSortOrder = { - name: ["3", "4", "1", "2", "5"], - createdDateTime: ["4", "5", "2", "3", "1"], - }[sortType]; - const iModels: IModelFull[] = [ - { - id: "1", - displayName: "d", - name: "c", - description: "e", - state: "initialized", - createdDateTime: "2020-09-05T12:42:51.593Z", - }, - { - id: "2", - displayName: "a", - name: "d", - description: "d", - state: "initialized", - createdDateTime: "2020-09-03T12:42:51.593Z", - }, - { - id: "3", - displayName: "e", - name: "a", - description: "c", - state: "notInitialized", - createdDateTime: "2020-09-04T12:42:51.593Z", - }, - { - id: "4", - displayName: "b", - name: "b", - description: "b", - state: "notInitialized", - createdDateTime: "2020-09-01T12:42:51.593Z", - }, - { - id: "5", - displayName: "c", - name: "d", - description: "a", - state: "initialized", - createdDateTime: "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"]; @@ -82,7 +89,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 +97,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 +105,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 +113,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 +121,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/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index 62d95295..9417dde3 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -22,6 +22,7 @@ function isSupportedSortType( "description", "initialized", "createdDateTime", + "lastChangesetPushDateTime", ].includes(sortType) ); } @@ -53,8 +54,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/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/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index c5489bdd..4eb6f106 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -22,7 +22,10 @@ import { } from "../../../utils/_buildMenuOptions"; import { BaseCardLoading } from "../../components/baseCard/BaseCardLoading"; import { NoResultsMUI } from "../../components/noResults/NoResultsMUI"; -import { type ITwinTableOverridesMUI } from "../../types"; +import { + type ITwinSortOptionsMUI, + type ITwinTableOverridesMUI, +} from "../../types"; import { stripNonTileProps } from "../../utils/stripNonTileProps"; import { type ITwinTableMUIStrings, ITwinTableMUI } from "./ITwinTableMUI"; import { type ITwinTilePropsMUI, ITwinTileMUI } from "./ITwinTileMUI"; @@ -53,6 +56,7 @@ export interface ITwinGridPropsMUI | "stringsOverrides" | "status" | "onOpen" + | "orderbyOptions" > { /** * Factory that returns actions for a given iTwin. @@ -82,6 +86,18 @@ export interface ITwinGridPropsMUI nonce?: string; /** Localized string overrides - falls back to default English strings if not provided */ stringsOverrides?: Partial; + /** + * Requested sort, e.g. `{ field: "displayName", direction: "asc" }`. + * `undefined` means the default server sort. + */ + sortOptions?: ITwinSortOptionsMUI; + /** + * 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. Receives `undefined` + * when the sort is cleared. + */ + onSortOptionsChange?: (sortOptions?: ITwinSortOptionsMUI) => void; } /** @@ -100,7 +116,7 @@ const ITwinGridMUIInternal = ({ accessToken, apiOverrides, filterOptions, - orderbyOptions, + sortOptions, actions, moreActions, requestType, @@ -112,6 +128,7 @@ const ITwinGridMUIInternal = ({ viewMode, tableOverrides, className, + onSortOptionsChange, nonce, }: ITwinGridPropsMUI) => { const logger = useLogger(); @@ -123,6 +140,15 @@ const ITwinGridMUIInternal = ({ resetShouldRefetchFavorites, } = useITwinFavorites(accessToken, apiOverrides?.serverEnvironmentPrefix); + // Translate the `sortOptions` prop into the OData `$orderby` string expected + // by the iTwins API (e.g. "displayName asc"). + const sortField = sortOptions?.field; + const sortDirection = sortOptions?.direction; + const orderbyOptions = React.useMemo( + () => (sortField ? `${sortField} ${sortDirection}` : undefined), + [sortField, sortDirection] + ); + const strings = React.useMemo( () => _mergeStrings( @@ -305,6 +331,8 @@ const ITwinGridMUIInternal = ({ tableOverrides={tableOverrides} isLoading={fetchStatus === DataStatus.Fetching} fetchMore={fetchMore} + sortOptions={sortOptions} + onSortOptionsChange={onSortOptionsChange} 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 09b8c1ed..e6500f65 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinTableMUI.tsx @@ -21,7 +21,11 @@ import { import { formatDate } from "../../../utils/formatDate"; import MoreMenuMUI from "../../components/MoreMenuMUI"; import { FavoriteIconMUI } from "../../components/tileFavoriteIcon/FavoriteIconMUI"; -import { type ITwinTableOverridesMUI } from "../../types"; +import { + type ITwinSortOptionsMUI, + type ITwinTableOverridesMUI, + type ITwinTableSortModel, +} from "../../types"; const EMPTY_COLUMN_OVERRIDES: NonNullable< ITwinTableOverridesMUI["columnOverrides"] @@ -66,8 +70,20 @@ export interface ITwinTableMUIProps { /** Called when more data should be loaded. */ fetchMore?: (() => void) | false; /** - * Nonce applied to `