diff --git a/common/changes/@itwin/imodel-browser-react/alex-allow-orderby_2025-09-10-17-41.json b/common/changes/@itwin/imodel-browser-react/alex-allow-orderby_2025-09-10-17-41.json new file mode 100644 index 00000000..444aa713 --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/alex-allow-orderby_2025-09-10-17-41.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Add option to pass $orderby API query param", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index f7b7e234..127be1b1 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -75,6 +75,14 @@ export interface ITwinGridProps { * A function allow filtering on anything, is used in a normal array.filter. */ filterOptions?: ITwinFilterOptions; + /** + * Set the `$orderby` parameter when fetching iTwins from the iTwin API, e.g. `displayName ASC`. + * + * This only has an effect when the `requestType` is empty - it does not apply to "favorites" or "recents". + * + * See https://developer.bentley.com/apis/itwins/operations/get-my-itwins/#odata-queries for details. + */ + orderbyOptions?: string; /** List of actions to build for each iTwin context menu. */ iTwinActions?: ContextMenuBuilderItem[]; /** Function (can be a react hook) that returns state for an iTwin, returned values will be applied as props to the iTwinTile, overrides ITwinGrid provided values */ @@ -109,6 +117,7 @@ export const ITwinGrid = ({ accessToken, apiOverrides, filterOptions, + orderbyOptions, onThumbnailClick, iTwinActions, requestType, @@ -156,6 +165,7 @@ export const ITwinGrid = ({ accessToken, apiOverrides, filterOptions, + orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, }); diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts index ee5c41b2..8c0f4f3d 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts @@ -3,24 +3,35 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { renderHook } from "@testing-library/react-hooks"; -import { rest } from "msw"; +import { + type ResponseComposition, + type RestContext, + type RestRequest, + rest, +} from "msw"; import { server } from "../../tests/mocks/server"; import { DataStatus } from "../../types"; import { useITwinData } from "./useITwinData"; describe("useITwinData hook", () => { + const accessToken = "accessToken"; + const urlWatcher = jest.fn(); + // Establish API mocking before all tests. beforeAll(() => server.listen()); // Reset any request handlers that we may add during the tests, // so they don't affect other tests. - afterEach(() => server.resetHandlers()); + afterEach(() => { + server.resetHandlers(); + jest.clearAllMocks(); + }); // Clean up after the tests are finished. afterAll(() => server.close()); it("returns all iTwins and proper status on successful call", async () => { const { result, waitForNextUpdate } = renderHook(() => - useITwinData({ accessToken: "accessToken" }) + useITwinData({ accessToken }) ); await waitForNextUpdate(); @@ -32,7 +43,7 @@ describe("useITwinData hook", () => { }); it("returns favorite iTwins and proper status on successful call", async () => { const { result, waitForNextUpdate } = renderHook(() => - useITwinData({ accessToken: "accessToken", requestType: "favorites" }) + useITwinData({ accessToken, requestType: "favorites" }) ); await waitForNextUpdate(); @@ -44,7 +55,7 @@ describe("useITwinData hook", () => { }); it("returns recent iTwins and proper status on successful call", async () => { const { result, waitForNextUpdate } = renderHook(() => - useITwinData({ accessToken: "accessToken", requestType: "recents" }) + useITwinData({ accessToken, requestType: "recents" }) ); await waitForNextUpdate(); @@ -56,7 +67,7 @@ describe("useITwinData hook", () => { }); it("returns searched iTwins and proper status on successful call", async () => { const { result, waitForNextUpdate } = renderHook(() => - useITwinData({ accessToken: "accessToken", filterOptions: "searched" }) + useITwinData({ accessToken, filterOptions: "searched" }) ); await waitForNextUpdate(); @@ -75,7 +86,7 @@ describe("useITwinData hook", () => { ); const { result, waitForValueToChange } = renderHook(() => - useITwinData({ accessToken: "accessToken" }) + useITwinData({ accessToken }) ); await waitForValueToChange(() => result.current.status); @@ -86,10 +97,10 @@ describe("useITwinData hook", () => { it("returns apiOverrides.data without fetching when it is provided", async () => { const data = [{ id: "rerenderedId", displayName: "rerenderedDisplayName" }]; const fetchData = [{ id: "fetchedId", displayName: "fetchedDisplayName" }]; - const watcher = jest.fn(); + server.use( rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => { - watcher(); + urlWatcher(req.url.toString()); return res(ctx.status(200), ctx.json({ iTwins: fetchData })); }) ); @@ -98,12 +109,12 @@ describe("useITwinData hook", () => { Parameters, ReturnType >((initialValue) => useITwinData(...initialValue), { - initialProps: [{ accessToken: "accessToken" }], + initialProps: [{ accessToken }], }); await waitForNextUpdate(); - expect(watcher).toHaveBeenCalledTimes(1); + expect(urlWatcher).toHaveBeenCalledTimes(1); expect(result.current.status).toEqual(DataStatus.Complete); expect(result.current.iTwins).toEqual(fetchData); @@ -119,14 +130,14 @@ describe("useITwinData hook", () => { { id: "rerenderedId", displayName: "rerenderedDisplayName" }, ]); expect(result.current.status).toEqual(DataStatus.Complete); - expect(watcher).toHaveBeenCalledTimes(1); + expect(urlWatcher).toHaveBeenCalledTimes(1); - rerender([{ accessToken: "accessToken" }]); + rerender([{ accessToken }]); await waitForNextUpdate(); expect(result.current.iTwins).toEqual(fetchData); expect(result.current.status).toEqual(DataStatus.Complete); - expect(watcher).toHaveBeenCalledTimes(2); + expect(urlWatcher).toHaveBeenCalledTimes(2); }); it("returns proper error if no accessToken is provided without data override", async () => { @@ -174,4 +185,99 @@ describe("useITwinData hook", () => { expect(result.current.iTwins.map((iTwin) => iTwin.id)).toEqual(expected); }); + + describe("orderByOptions", () => { + const orderbyOptions = "displayName DESC"; + const fetchedITwins = [ + { id: "fetchedId", displayName: "fetchedDisplayName" }, + ]; + + const handleRequest = ( + req: RestRequest, + res: ResponseComposition, + ctx: RestContext + ) => { + urlWatcher(req.url.toString()); + return res(ctx.status(200), ctx.json({ iTwins: fetchedITwins })); + }; + + it("returns ordered iTwins and proper status on successful call", async () => { + server.use(rest.get("https://api.bentley.com/itwins/", handleRequest)); + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, orderbyOptions: "displayName ASC" }) + ); + + await waitForNextUpdate(); + + expect(result.current.iTwins).toEqual(fetchedITwins); + expect(urlWatcher).toHaveBeenCalledWith( + expect.stringContaining("$orderby=displayName%20ASC") + ); + expect(result.current.status).toEqual(DataStatus.Complete); + }); + + it("ignores orderBy options for favorites request", async () => { + server.use( + rest.get("https://api.bentley.com/itwins/favorites", handleRequest) + ); + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, requestType: "favorites", orderbyOptions }) + ); + + await waitForNextUpdate(); + expect(urlWatcher).toHaveBeenCalledWith( + expect.not.stringContaining("$orderby") + ); + expect(result.current.status).toEqual(DataStatus.Complete); + }); + + it("ignores orderBy options for recents request", async () => { + server.use( + rest.get("https://api.bentley.com/itwins/recents", handleRequest) + ); + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, requestType: "recents", orderbyOptions }) + ); + + await waitForNextUpdate(); + expect(urlWatcher).toHaveBeenCalledWith( + expect.not.stringContaining("$orderby") + ); + expect(result.current.status).toEqual(DataStatus.Complete); + }); + + it("properly encodes orderBy options with special characters", async () => { + server.use(rest.get("https://api.bentley.com/itwins/", handleRequest)); + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, orderbyOptions }) + ); + + await waitForNextUpdate(); + expect(urlWatcher).toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent(orderbyOptions)) + ); + expect(result.current.status).toEqual(DataStatus.Complete); + }); + + it("refetches data when orderBy options change", async () => { + server.use(rest.get("https://api.bentley.com/itwins/", handleRequest)); + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((initialValue) => useITwinData(...initialValue), { + initialProps: [{ accessToken, orderbyOptions }], + }); + + await waitForNextUpdate(); + expect(urlWatcher).toHaveBeenCalledTimes(1); + + rerender([{ accessToken, orderbyOptions: "somethingDifferent" }]); + await waitForNextUpdate(); + expect(urlWatcher).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index a56bb838..5be1b20a 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -20,6 +20,7 @@ export interface ProjectDataHookOptions { accessToken?: string | (() => Promise) | undefined; apiOverrides?: ApiOverrides; filterOptions?: ITwinFilterOptions; + orderbyOptions?: string; shouldRefetchFavorites?: boolean; resetShouldRefetchFavorites?: () => void; } @@ -32,6 +33,7 @@ export const useITwinData = ({ accessToken, apiOverrides, filterOptions, + orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, }: ProjectDataHookOptions) => { @@ -76,6 +78,7 @@ export const useITwinData = ({ accessToken, requestType, iTwinSubClass, + orderbyOptions, data, serverEnvironmentPrefix, refetchData, @@ -110,9 +113,14 @@ export const useITwinData = ({ ["favorites", "recents"].includes(requestType) || !filterOptions ? "" : `&$search=${encodeURIComponent(String(filterOptions).trim())}`; + const orderby = + ["favorites", "recents"].includes(requestType) || !orderbyOptions + ? "" + : `&$orderby=${encodeURIComponent(String(orderbyOptions).trim())}`; + const url = `${_getAPIServer( serverEnvironmentPrefix - )}/itwins/${endpoint}${subClass}${paging}${search}`; + )}/itwins/${endpoint}${subClass}${paging}${search}${orderby}`; const makeFetchRequest = async () => { const options: RequestInit = { @@ -165,6 +173,7 @@ export const useITwinData = ({ data, serverEnvironmentPrefix, filterOptions, + orderbyOptions, page, morePages, iTwinSubClass,