From ee6e4a3817aa376cb908b18103d6e0ad7df6f5ef Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 13:10:59 -0400 Subject: [PATCH 01/34] feat: update @itwin/itwinui-react to version 3.19.4 in storybook and add iModel recents/favorites functionality - Added new features to manage iModel favorites, including a context provider and custom hooks. - Enhanced IModelTile components to support favorites functionality. - Implemented tests for the new favorites hook. - Updated IModelGrid to handle favorites and recents request types. --- common/config/rush/pnpm-lock.yaml | 9 +- packages/apps/storybook/package.json | 2 +- .../src/imodel-browser/IModelTile.stories.tsx | 23 ++ .../src/imodel-browser/ITwinTile.stories.tsx | 1 + .../src/containers/ITwinGrid/ITwinTile.tsx | 32 +-- .../src/containers/iModelGrid/IModelGrid.tsx | 51 +++- .../iModelGrid/useIIModelFavorites.test.ts | 85 +++++++ .../iModelGrid/useIIModelFavorites.ts | 221 ++++++++++++++++++ .../containers/iModelGrid/useIModelData.ts | 25 +- .../containers/iModelGrid/useIModelSort.ts | 8 +- .../iModelGrid/useIModelTableConfig.tsx | 38 ++- .../src/containers/iModelTiles/IModelTile.tsx | 94 +++++++- .../src/contexts/IModelFavoritesContext.tsx | 82 +++++++ 13 files changed, 637 insertions(+), 34 deletions(-) create mode 100644 packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts create mode 100644 packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts create mode 100644 packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 749f84f8..2bba42fb 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -21,7 +21,7 @@ importers: '@itwin/delete-itwin-react': workspace:* '@itwin/imodel-browser-react': workspace:* '@itwin/itwinui-icons-react': 2.10.0 - '@itwin/itwinui-react': 3.18.0 + '@itwin/itwinui-react': 3.19.4 '@itwin/manage-versions-react': workspace:* '@itwin/storybook-auth-addon': workspace:* '@storybook/addon-actions': ^6.5.16 @@ -66,7 +66,7 @@ importers: '@itwin/delete-itwin-react': link:../../modules/delete-itwin '@itwin/imodel-browser-react': link:../../modules/imodel-browser '@itwin/itwinui-icons-react': 2.10.0_nnrd3gsncyragczmpvfhocinkq - '@itwin/itwinui-react': 3.18.0_nnrd3gsncyragczmpvfhocinkq + '@itwin/itwinui-react': 3.19.4_nnrd3gsncyragczmpvfhocinkq '@itwin/manage-versions-react': link:../../modules/manage-versions '@itwin/storybook-auth-addon': link:../../modules/storybook-auth-addon '@storybook/addon-actions': 6.5.16_nnrd3gsncyragczmpvfhocinkq @@ -2739,6 +2739,7 @@ packages: react: 18.3.1 react-dom: 18.3.1_react@18.3.1 tabbable: 6.2.0 + dev: false /@floating-ui/utils/0.2.10: resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} @@ -2864,6 +2865,7 @@ packages: react: 18.3.1 react-dom: 18.3.1_react@18.3.1 react-table: 7.8.0_react@18.3.1 + dev: false /@itwin/itwinui-react/3.19.4_nnrd3gsncyragczmpvfhocinkq: resolution: {integrity: sha512-DEKmeMyW2w3DDyzt1KHrfXmyj1A0DkTbgF7R+PvrQrkDVh5+08vnAV/Q+EK9fkKUOuRL8P0lvhMhIPhRa1l1eQ==} @@ -5312,6 +5314,7 @@ packages: resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} dependencies: tslib: 2.8.1 + dev: false /@swc/helpers/0.5.17: resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} @@ -5339,6 +5342,7 @@ packages: '@tanstack/virtual-core': 3.13.6 react: 18.3.1 react-dom: 18.3.1_react@18.3.1 + dev: false /@tanstack/virtual-core/3.13.12: resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} @@ -5346,6 +5350,7 @@ packages: /@tanstack/virtual-core/3.13.6: resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} + dev: false /@testing-library/dom/10.4.0: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} diff --git a/packages/apps/storybook/package.json b/packages/apps/storybook/package.json index ba39d9ff..b81808a3 100644 --- a/packages/apps/storybook/package.json +++ b/packages/apps/storybook/package.json @@ -14,7 +14,7 @@ "@itwin/delete-itwin-react": "workspace:*", "@itwin/imodel-browser-react": "workspace:*", "@itwin/itwinui-icons-react": "2.10.0", - "@itwin/itwinui-react": "3.18.0", + "@itwin/itwinui-react": "3.19.4", "@itwin/manage-versions-react": "workspace:*", "@itwin/storybook-auth-addon": "workspace:*", "@storybook/addon-actions": "^6.5.16", diff --git a/packages/apps/storybook/src/imodel-browser/IModelTile.stories.tsx b/packages/apps/storybook/src/imodel-browser/IModelTile.stories.tsx index 52d304b2..fdec087b 100644 --- a/packages/apps/storybook/src/imodel-browser/IModelTile.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/IModelTile.stories.tsx @@ -3,6 +3,8 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { IModelTile as C, IModelTileProps } from "@itwin/imodel-browser-react"; +import { SvgImodel, SvgPlaceholder } from "@itwin/itwinui-icons-react"; +import { Badge, Button } from "@itwin/itwinui-react"; import { Meta, Story } from "@storybook/react/types-6-0"; import React from "react"; @@ -20,5 +22,26 @@ export const Primary = Template.bind({}); Primary.args = { iModel: { id: "1", + displayName: "iModel Name", + description: "iModel Description", + }, + iModelOptions: [ + { key: "option-1", children: "Option 1" }, + { key: "option-2", children: "Option 2" }, + ], + tileProps: { + status: "positive", + isDisabled: false, + isLoading: false, + isSelected: false, + isNew: false, + badge: Badge, + leftIcon: , + rightIcon: , + buttons: [ + , + , + ], + thumbnail: , }, }; diff --git a/packages/apps/storybook/src/imodel-browser/ITwinTile.stories.tsx b/packages/apps/storybook/src/imodel-browser/ITwinTile.stories.tsx index 678259db..42965b88 100644 --- a/packages/apps/storybook/src/imodel-browser/ITwinTile.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/ITwinTile.stories.tsx @@ -20,5 +20,6 @@ export const Primary = Template.bind({}); Primary.args = { iTwin: { id: "1", + displayName: "iTwin Name", }, }; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx index e9d4d8c8..9a19c478 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx @@ -130,21 +130,23 @@ export const ITwinTile = ({ {leftIcon && {leftIcon}} {rightIcon} - { - isFavorite - ? await removeFromFavorites?.(iTwin.id) - : await addToFavorites?.(iTwin.id); - }} - styleType="borderless" - > - {isFavorite ? : } - + {isFavorite !== undefined && ( + { + isFavorite + ? await removeFromFavorites?.(iTwin.id) + : await addToFavorites?.(iTwin.id); + }} + styleType="borderless" + > + {isFavorite ? : } + + )} {badge ?? diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index 7626788b..ad8eeba7 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -8,6 +8,7 @@ import { InView } from "react-intersection-observer"; import { GridStructure } from "../../components/gridStructure/GridStructure"; import { NoResults } from "../../components/noResults/NoResults"; +import { IModelFavoritesProvider } from "../../contexts/IModelFavoritesContext"; import { ApiOverrides, DataStatus, @@ -16,7 +17,7 @@ import { IModelSortOptions, ViewType, } from "../../types"; -import { _mergeStrings } from "../../utils/_apiOverrides"; +import { _getAPIServer, _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; @@ -30,6 +31,7 @@ export interface IModelGridProps { accessToken?: string | (() => Promise) | undefined; /** ITwin Id to list the iModels from (mutually exclusive to assetId) */ iTwinId?: string | undefined; + requestType?: "favorites" | "recents" | ""; /** Thumbnail click handler. */ onThumbnailClick?(iModel: IModelFull): void; /** Configure IModel sorting behavior. @@ -100,13 +102,25 @@ export interface IModelGridProps { /** * Component that will allow displaying a grid of iModels, given a contextId */ -export const IModelGrid = ({ +export const IModelGrid = (props: IModelGridProps) => { + return ( + + + + ); +}; +const IModelGridInner = ({ accessToken, apiOverrides, iModelActions, onThumbnailClick, iTwinId, sortOptions = { sortType: "name", descending: false }, + requestType, stringsOverrides, tileOverrides, useIndividualState, @@ -141,6 +155,7 @@ export const IModelGrid = ({ const strings = _mergeStrings( { + tableColumnFavorites: "", tableColumnName: "Name", tableColumnDescription: "Description", tableColumnLastModified: "Last Modified", @@ -152,6 +167,8 @@ export const IModelGrid = ({ noContext: "No context provided", noAuthentication: "No access token provided", error: "An error occurred", + addToFavorites: "Add to favorites", + removeFromFavorites: "Remove from favorites", }, stringsOverrides ); @@ -161,6 +178,7 @@ export const IModelGrid = ({ fetchMore, refetchIModels, } = useIModelData({ + requestType, accessToken, apiOverrides, iTwinId, @@ -190,7 +208,33 @@ export const IModelGrid = ({ const { columns, onRowClick } = useIModelTableConfig({ iModelActions, - onThumbnailClick, + onThumbnailClick: async (iModel) => { + try { + if (!accessToken) { + onThumbnailClick?.(iModel); + return; + } + + const url = `${_getAPIServer( + apiOverrides?.serverEnvironmentPrefix + )}/imodels/recents/${encodeURIComponent(iModel.id)}`; + + void fetch(url, { + method: "POST", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + } catch (e) { + // swallow errors to avoid disrupting the UI + console.error("Failed to add iModel to recents", e); + } + onThumbnailClick?.(iModel); + }, strings, refetchIModels, cellOverrides, @@ -223,6 +267,7 @@ export const IModelGrid = ({ apiOverrides={tileApiOverrides} useTileState={useIndividualState} refetchIModels={refetchIModels} + {...cellOverrides} {...tileOverrides} /> ))} diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts new file mode 100644 index 00000000..b48ffb13 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { renderHook } from "@testing-library/react-hooks"; +import { act } from "react"; + +import { useIModelFavorites } from "./useIIModelFavorites"; + +export function mockFetch(data: any, status = 200) { + return jest.fn().mockImplementationOnce(async () => + Promise.resolve({ + status, + ok: true, + json: () => data, + }) + ); +} + +const accessToken = "test-access-token"; +const iTwinId = "iTwinId"; + +describe("useIModelFavorites", () => { + // Clear mocks before each test + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should initialize with empty Set", () => { + const { result } = renderHook(() => + useIModelFavorites(iTwinId, accessToken) + ); + window.fetch = mockFetch({ iModels: [] }); + + expect(result.current.iModelFavorites).toBeInstanceOf(Set); + expect(result.current.iModelFavorites.size).toBe(0); + }); + + test("should fetch favorites on mount", async () => { + const mockFavorites = [ + { id: "1", name: "iModel1" }, + { id: "2", name: "iModel2" }, + ]; + + window.fetch = mockFetch({ iModels: mockFavorites }); + const { result, waitForNextUpdate } = renderHook(() => + useIModelFavorites(iTwinId, accessToken) + ); + + await waitForNextUpdate(); + expect(result.current.iModelFavorites.has("1")).toBe(true); + expect(result.current.iModelFavorites.has("2")).toBe(true); + expect(result.current.iModelFavorites.size).toBe(2); + }); + + test("should handle empty response from API", async () => { + window.fetch = mockFetch({ iModels: [] }); + + const { result, waitForNextUpdate } = renderHook(() => + useIModelFavorites(iTwinId, accessToken) + ); + + await waitForNextUpdate(); + expect(result.current.iModelFavorites.size).toBe(0); + }); + + test("should add and remove an iModel from favorites", async () => { + const { result } = renderHook(() => + useIModelFavorites(iTwinId, accessToken) + ); + const iModelId = "test-iModel-id"; + await act(async () => { + window.fetch = mockFetch({}); + await result.current.addIModelToFavorites(iModelId); + }); + expect(result.current.iModelFavorites.has(iModelId)).toBe(true); + + await act(async () => { + window.fetch = mockFetch({}); + await result.current.removeIModelFromFavorites(iModelId); + }); + expect(result.current.iModelFavorites.has(iModelId)).toBe(false); + }); +}); diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts new file mode 100644 index 00000000..ba396602 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { useCallback, useEffect, useState } from "react"; + +import { _getAPIServer } from "../../utils/_apiOverrides"; + +const HOOK_ABORT_ERROR = + "The fetch request was aborted by the cleanup function."; + +/** + * Custom hook to manage iModel favorites. + * @param {string | (() => Promise) | undefined} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param {ApiOverrides} [apiOverrides] - Optional API overrides. + * @returns {object} - An object containing: + * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. + * - {function} addIModelToFavorites - A function to add an iModel to favorites. + * - {function} removeIModelFromFavorites - A function to remove an iModel from favorites. + * - {boolean} shouldRefetchFavorites - A boolean indicating whether to refetch favorites when switching to the favorites tab. + * - {function} resetShouldRefetchFavorites - A function to reset shouldRefetchFavorites back to false. + */ +export const useIModelFavorites = ( + iTwinId: string, + accessToken: string | (() => Promise) | undefined, + serverEnvironmentPrefix?: "dev" | "qa" | "" +): { + iModelFavorites: Set; + addIModelToFavorites: (iTwinId: string) => Promise; + removeIModelFromFavorites: (iTwinId: string) => Promise; + shouldRefetchFavorites: boolean; + resetShouldRefetchFavorites: () => void; +} => { + const [iModelFavorites, setIModelFavorites] = useState(new Set()); + const [shouldRefetchFavorites, setShouldRefetchFavorites] = useState(false); + + /** + * Adds an iModel to the favorites. + * @param {string} iModelId - The ID of the iModel to add to favorites. + * @returns {Promise} + */ + const addIModelToFavorites = useCallback( + async (iModelId: string): Promise => { + if (!accessToken || !iModelId || iModelId === "") { + return; + } + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites/${iModelId}`; + try { + const result = await fetch(url, { + method: "PUT", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + + if (!result || (result.status !== 200 && result.status !== 204)) { + throw new Error(`Failed to add iModel ${iModelId} to favorites`); + } + + setIModelFavorites((prev) => new Set([...prev, iModelId])); + setShouldRefetchFavorites(true); + } catch (error) { + console.error(error); + } + }, + [accessToken, serverEnvironmentPrefix] + ); + + /** + * Removes an iModel from the favorites. + * @param {string} iModelId - The ID of the iModel to remove from favorites. + * @returns {Promise} + */ + const removeIModelFromFavorites = useCallback( + async (iModelId: string): Promise => { + if (!accessToken || !iModelId || iModelId === "") { + return; + } + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites/${iModelId}`; + try { + const result = await fetch(url, { + method: "DELETE", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + + if (!result || (result.status !== 200 && result.status !== 204)) { + throw new Error(`Failed to remove iModel ${iModelId} to favorites`); + } + + setIModelFavorites((prev) => { + const newFavorites = new Set(prev); + newFavorites.delete(iModelId); + return newFavorites; + }); + setShouldRefetchFavorites(true); + } catch (error) { + console.error(error); + } + }, + [accessToken, serverEnvironmentPrefix] + ); + + /** + * Fetches iTwin favorites from the API. + * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. + * @returns {Promise} - A promise that resolves to an array of iTwin favorites. + * @throws {Error} - Throws an error if the fetch request fails. + */ + const getIModelFavorites = useCallback( + async (abortSignal?: AbortSignal): Promise => { + if (!accessToken) { + return []; + } + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites?iTwinId=${iTwinId}`; + const result = await fetch(url, { + headers: { + "Cache-Control": shouldRefetchFavorites ? "no-cache" : "", + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + signal: abortSignal, + }); + if (abortSignal?.aborted) { + throw new Error(HOOK_ABORT_ERROR); + } + if (!result) { + throw new Error( + `Failed to fetch iModels favorites from ${url}.\nNo response.` + ); + } + if (result.status !== 200) { + throw new Error( + `Failed to fetch iModels favorites from ${url}.\nStatus: ${result.status}` + ); + } + const response: IModelFavoritesResponse = await result.json(); + return response.iModels; + }, + [accessToken, iTwinId, serverEnvironmentPrefix, shouldRefetchFavorites] + ); + + const resetShouldRefetchFavorites = useCallback(() => { + setShouldRefetchFavorites(false); + }, []); + + useEffect(() => { + const controller = new AbortController(); + /** + * Fetches iTwin favorites and updates the state. + * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. + */ + const fetchIModelFavorites = async (abortSignal?: AbortSignal) => { + try { + const favorites = await getIModelFavorites(abortSignal); + setIModelFavorites(new Set(favorites.map((favorite) => favorite.id))); + } catch (error) { + if ( + error === HOOK_ABORT_ERROR || + (error instanceof Error && error.name === "AbortError") + ) { + return; + } + console.error(error); + } + }; + void fetchIModelFavorites(controller.signal); + + return () => { + controller.abort(); + }; + }, [getIModelFavorites]); + + return { + iModelFavorites, + addIModelToFavorites, + removeIModelFromFavorites, + shouldRefetchFavorites, + resetShouldRefetchFavorites, + }; +}; + +/** Response from https://developer.bentley.com/apis/imodels-v2/operations/get-my-favorite-imodels/ */ +interface IModelFavoritesResponse { + iModels: IModelFavorites[]; + _links: { + self: { + href: string; + }; + prev?: { + href: string; + }; + next?: { + href: string; + }; + }; +} +interface IModelFavorites { + id: string; + displayName: string; + dataCenterLocation: string; +} diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index be017487..4fd37b05 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts @@ -15,6 +15,7 @@ import { _getAPIServer } from "../../utils/_apiOverrides"; import { useIModelSort } from "./useIModelSort"; export interface IModelDataHookOptions { + requestType?: "favorites" | "recents" | ""; iTwinId?: string | undefined; accessToken?: string | (() => Promise) | undefined; sortOptions?: IModelSortOptions; @@ -28,6 +29,7 @@ export interface IModelDataHookOptions { export const DEFAULT_PAGE_SIZE = 100; export const useIModelData = ({ + requestType = "", iTwinId, accessToken, sortOptions, @@ -53,7 +55,11 @@ export const useIModelData = ({ IModelSortOptions | undefined >(sortOptions && { ...sortOptions }); const sortDescending = sortOptions?.descending; - const sortedIModels = useIModelSort(iModels, sortOptions); + const sortedIModels = useIModelSort( + iModels, + sortOptions, + requestType === "recents" + ); const sortChanged = sortOptions?.descending !== previousSortOptions?.descending || sortOptions?.sortType !== previousSortOptions?.sortType; @@ -82,9 +88,9 @@ export const useIModelData = ({ ) { return; } - setPage(page + 1); + setPage((page) => page + 1); setNeedsUpdate(true); - }, [needsUpdate, status, morePagesAvailable, page]); + }, [needsUpdate, status, morePagesAvailable]); React.useEffect(() => { // start from scratch when any external state changes @@ -99,6 +105,7 @@ export const useIModelData = ({ searchText, pageSize, maxCount, + requestType, reset, ]); @@ -147,7 +154,8 @@ export const useIModelData = ({ searchText, pageSize, maxCount, - apiOverrides?.serverEnvironmentPrefix + apiOverrides?.serverEnvironmentPrefix, + requestType ); setAbortController(newAbortController); @@ -181,6 +189,7 @@ export const useIModelData = ({ morePagesAvailable, needsUpdate, page, + requestType, searchText, sortChanged, sortDescending, @@ -204,7 +213,8 @@ const createFetchIModelsFn = ( searchText: string | undefined, pageSize: number = DEFAULT_PAGE_SIZE, maxCount: number | undefined, - serverEnvironmentPrefix?: "" | "dev" | "qa" + serverEnvironmentPrefix?: "" | "dev" | "qa", + requestType: "favorites" | "recents" | "" = "" ): { abortController: AbortController; fetchIModels: () => Promise<{ @@ -231,6 +241,9 @@ const createFetchIModelsFn = ( }; } + const endpoint = ["favorites", "recents"].includes(requestType) + ? requestType + : ""; const top = maxCount ? Math.min(pageSize, maxCount - skip) : pageSize; const paging = `&$skip=${skip}&$top=${top}`; const searching = searchText?.trim() @@ -240,7 +253,7 @@ const createFetchIModelsFn = ( const abortController = new AbortController(); const url = `${_getAPIServer( serverEnvironmentPrefix - )}/imodels/${selection}${sorting}${paging}${searching}`; + )}/imodels/${endpoint}${selection}${sorting}${paging}${searching}`; const doFetchRequest = async () => { const options: RequestInit = { diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index aed26d8a..a5d378a0 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -32,7 +32,8 @@ const sortStringValues = (a: string, b: string) => a.localeCompare(b); export const useIModelSort = ( iModels: IModelFull[], - options?: IModelSortOptions + options?: IModelSortOptions, + skipSort = false ) => { const sortType = typeof options !== "function" ? options?.sortType : undefined; @@ -40,6 +41,9 @@ export const useIModelSort = ( typeof options !== "function" ? options?.descending ?? false : undefined; const sortFn = typeof options === "function" ? options : undefined; return React.useMemo(() => { + if (skipSort) { + return iModels; + } if (sortFn) { return [...iModels].sort(sortFn); } @@ -61,5 +65,5 @@ export const useIModelSort = ( } ); return descending ? sorted.reverse() : sorted; - }, [sortFn, sortType, iModels, descending]); + }, [skipSort, iModels, sortFn, sortType, descending]); }; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx index 01cf8f5e..6850ca37 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx @@ -2,12 +2,13 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { SvgMore } from "@itwin/itwinui-icons-react"; +import { SvgMore, SvgStar, SvgStarHollow } from "@itwin/itwinui-icons-react"; import { DropdownMenu, IconButton } from "@itwin/itwinui-react"; import React from "react"; import { useMemo } from "react"; import { CellProps } from "react-table"; +import { useIModelFavoritesContext } from "../../contexts/IModelFavoritesContext"; import { IModelCellOverrides, IModelFull } from "../../types"; import { _buildManagedContextMenuOptions, @@ -26,6 +27,9 @@ export interface useIModelTableConfigProps { noContext: string; noAuthentication: string; error: string; + tableColumnFavorites: string; + addToFavorites: string; + removeFromFavorites: string; }; refetchIModels: () => void; cellOverrides?: IModelCellOverrides; @@ -38,6 +42,7 @@ export const useIModelTableConfig = ({ refetchIModels, cellOverrides = {}, }: useIModelTableConfigProps) => { + const favoritesContext = useIModelFavoritesContext(); const onRowClick = (_: React.MouseEvent, row: any) => { const iModel = row.original as IModelFull; if (!iModel) { @@ -51,6 +56,33 @@ export const useIModelTableConfig = ({ { Header: "Table", columns: [ + { + id: "Favorite", + Header: strings.tableColumnFavorites, + accessor: "id", + width: 70, + Cell: (props: CellProps) => { + const isFavorite = favoritesContext?.favorites.has(props.value); + return ( + { + e.stopPropagation(); + isFavorite + ? await favoritesContext?.remove?.(props.value) + : await favoritesContext?.add?.(props.value); + }} + > + {isFavorite ? : } + + ); + }, + }, { id: "name", Header: strings.tableColumnName, @@ -134,9 +166,13 @@ export const useIModelTableConfig = ({ }, ], [ + strings.tableColumnFavorites, strings.tableColumnName, strings.tableColumnDescription, strings.tableColumnLastModified, + strings.addToFavorites, + strings.removeFromFavorites, + favoritesContext, cellOverrides, iModelActions, refetchIModels, diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 09242d50..b7f5740a 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -2,10 +2,13 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { Tile } from "@itwin/itwinui-react"; +import { SvgStar, SvgStarHollow } from "@itwin/itwinui-icons-react"; +import { IconButton, Tile } from "@itwin/itwinui-react"; import React from "react"; +import { useIModelFavoritesContext } from "../../contexts/IModelFavoritesContext"; import { ApiOverrides, IModelFull } from "../../types"; +import { _getAPIServer, _mergeStrings } from "../../utils/_apiOverrides"; import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, @@ -23,6 +26,13 @@ export interface IModelTileProps { iModelOptions?: ContextMenuBuilderItem[]; /** Function to call on thumbnail click */ onThumbnailClick?(iModel: IModelFull): void; + /** Strings displayed by the browser */ + stringsOverrides?: { + /** Accessible text for the hollow star icon to add the iModel to favorites */ + addToFavorites?: string; + /** Accessible text for the full star icon to remove the iModel from favorites */ + removeFromFavorites?: string; + }; /** Tile props that will be applied after normal use. (Will override IModelTile if used) */ tileProps?: Partial< TileProps & { getBadge?: (iModel: IModelFull) => React.ReactNode } @@ -45,6 +55,7 @@ export const IModelTile = ({ onThumbnailClick, apiOverrides, tileProps, + stringsOverrides, refetchIModels, fullWidth, }: IModelTileProps) => { @@ -66,6 +77,14 @@ export const IModelTile = ({ metadata, ...rest } = tileProps ?? {}; + const favoritesContext = useIModelFavoritesContext(); + const strings = _mergeStrings( + { + addToFavorites: "Add to favorites", + removeFromFavorites: "Remove from favorites", + }, + stringsOverrides + ); const moreOptionsBuilt = React.useMemo( () => @@ -85,6 +104,45 @@ export const IModelTile = ({ } : undefined; + const internalOnClick = React.useCallback( + async (e: React.MouseEvent) => { + if (favoritesContext) { + try { + if (!accessToken) { + return; + } + + const url = `${_getAPIServer( + apiOverrides?.serverEnvironmentPrefix + )}/imodels/recents/${encodeURIComponent(iModel.id)}`; + + void fetch(url, { + method: "POST", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + } catch (e) { + // swallow errors to avoid disrupting the UI + console.error("Failed to add iModel to recents", e); + } + } + onClick?.(e) ?? onThumbnailClick?.(iModel); + }, + [ + accessToken, + apiOverrides?.serverEnvironmentPrefix, + favoritesContext, + iModel, + onClick, + onThumbnailClick, + ] + ); + return ( onClick?.(e) ?? onThumbnailClick?.(iModel)} + onClick={internalOnClick} aria-disabled={isDisabled} data-testid={`iModel-tile-${iModel.id}-name-label`} > @@ -108,7 +166,35 @@ export const IModelTile = ({ {leftIcon && {leftIcon}} - {rightIcon && {rightIcon}} + + {rightIcon} + {favoritesContext && ( + { + favoritesContext.favorites.has(iModel.id) + ? await favoritesContext.remove?.(iModel.id) + : await favoritesContext.add?.(iModel.id); + }} + style={{ + paddingInline: "var(--iui-button-padding-block)", + backgroundColor: + "rgb(from var(--iui-color-background) r g b / 0.7)", + }} + styleType="borderless" + > + {favoritesContext.favorites.has(iModel.id) ? ( + + ) : ( + + )} + + )} + {thumbnail ? ( {thumbnail} ) : ( @@ -126,7 +212,7 @@ export const IModelTile = ({ onClick?.(e) ?? onThumbnailClick?.(iModel)} + onClick={internalOnClick} aria-disabled={isDisabled} data-testid={`iModel-tile-${iModel.id}-action`} > diff --git a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx new file mode 100644 index 00000000..1da213d4 --- /dev/null +++ b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import React from "react"; + +import { useIModelFavorites } from "../containers/iModelGrid/useIIModelFavorites"; + +export interface IModelFavoritesContextValue { + favorites: Set; + add: (iModelId: string) => Promise; + remove: (iModelId: string) => Promise; + /** @internal implementation detail for legacy flows */ + _shouldRefetch: boolean; + /** @internal implementation detail for legacy flows */ + _resetShouldRefetch: () => void; +} + +export const IModelFavoritesContext = React.createContext< + IModelFavoritesContextValue | undefined +>(undefined); + +export interface IModelFavoritesProviderProps { + iTwinId: string; + accessToken?: string | (() => Promise); + serverEnvironmentPrefix?: string; + children: React.ReactNode; +} + +export const IModelFavoritesProvider = ({ + iTwinId, + accessToken, + serverEnvironmentPrefix, + children, +}: IModelFavoritesProviderProps) => { + const { + iModelFavorites, + addIModelToFavorites, + removeIModelFromFavorites, + shouldRefetchFavorites, + resetShouldRefetchFavorites, + } = useIModelFavorites( + iTwinId, + accessToken, + serverEnvironmentPrefix === "dev" || serverEnvironmentPrefix === "qa" + ? serverEnvironmentPrefix + : undefined + ); + + const value = React.useMemo( + () => ({ + favorites: iModelFavorites, + add: addIModelToFavorites, + remove: removeIModelFromFavorites, + _shouldRefetch: shouldRefetchFavorites, + _resetShouldRefetch: resetShouldRefetchFavorites, + }), + [ + iModelFavorites, + addIModelToFavorites, + removeIModelFromFavorites, + shouldRefetchFavorites, + resetShouldRefetchFavorites, + ] + ); + + return ( + + {children} + + ); +}; + +export const useIModelFavoritesContext = () => { + const ctx = React.useContext(IModelFavoritesContext); + if (!ctx) { + console.warn( + "useIModelFavoritesContext must be used within IModelFavoritesProvider" + ); + } + return ctx; +}; From 5beb83c3c2a60b914a00ea36bac6f91a1023980d Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 13:27:42 -0400 Subject: [PATCH 02/34] refactor: remove unused refetch logic from useIModelFavorites and context --- .../iModelGrid/useIIModelFavorites.ts | 16 +-------- .../src/contexts/IModelFavoritesContext.tsx | 35 +++++-------------- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts index ba396602..0354fe86 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -18,8 +18,6 @@ const HOOK_ABORT_ERROR = * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. * - {function} addIModelToFavorites - A function to add an iModel to favorites. * - {function} removeIModelFromFavorites - A function to remove an iModel from favorites. - * - {boolean} shouldRefetchFavorites - A boolean indicating whether to refetch favorites when switching to the favorites tab. - * - {function} resetShouldRefetchFavorites - A function to reset shouldRefetchFavorites back to false. */ export const useIModelFavorites = ( iTwinId: string, @@ -29,11 +27,8 @@ export const useIModelFavorites = ( iModelFavorites: Set; addIModelToFavorites: (iTwinId: string) => Promise; removeIModelFromFavorites: (iTwinId: string) => Promise; - shouldRefetchFavorites: boolean; - resetShouldRefetchFavorites: () => void; } => { const [iModelFavorites, setIModelFavorites] = useState(new Set()); - const [shouldRefetchFavorites, setShouldRefetchFavorites] = useState(false); /** * Adds an iModel to the favorites. @@ -65,7 +60,6 @@ export const useIModelFavorites = ( } setIModelFavorites((prev) => new Set([...prev, iModelId])); - setShouldRefetchFavorites(true); } catch (error) { console.error(error); } @@ -107,7 +101,6 @@ export const useIModelFavorites = ( newFavorites.delete(iModelId); return newFavorites; }); - setShouldRefetchFavorites(true); } catch (error) { console.error(error); } @@ -131,7 +124,6 @@ export const useIModelFavorites = ( )}/imodels/favorites?iTwinId=${iTwinId}`; const result = await fetch(url, { headers: { - "Cache-Control": shouldRefetchFavorites ? "no-cache" : "", authorization: typeof accessToken === "function" ? await accessToken() @@ -156,13 +148,9 @@ export const useIModelFavorites = ( const response: IModelFavoritesResponse = await result.json(); return response.iModels; }, - [accessToken, iTwinId, serverEnvironmentPrefix, shouldRefetchFavorites] + [accessToken, iTwinId, serverEnvironmentPrefix] ); - const resetShouldRefetchFavorites = useCallback(() => { - setShouldRefetchFavorites(false); - }, []); - useEffect(() => { const controller = new AbortController(); /** @@ -194,8 +182,6 @@ export const useIModelFavorites = ( iModelFavorites, addIModelToFavorites, removeIModelFromFavorites, - shouldRefetchFavorites, - resetShouldRefetchFavorites, }; }; diff --git a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx index 1da213d4..9a358427 100644 --- a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx +++ b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx @@ -10,10 +10,6 @@ export interface IModelFavoritesContextValue { favorites: Set; add: (iModelId: string) => Promise; remove: (iModelId: string) => Promise; - /** @internal implementation detail for legacy flows */ - _shouldRefetch: boolean; - /** @internal implementation detail for legacy flows */ - _resetShouldRefetch: () => void; } export const IModelFavoritesContext = React.createContext< @@ -33,35 +29,22 @@ export const IModelFavoritesProvider = ({ serverEnvironmentPrefix, children, }: IModelFavoritesProviderProps) => { - const { - iModelFavorites, - addIModelToFavorites, - removeIModelFromFavorites, - shouldRefetchFavorites, - resetShouldRefetchFavorites, - } = useIModelFavorites( - iTwinId, - accessToken, - serverEnvironmentPrefix === "dev" || serverEnvironmentPrefix === "qa" - ? serverEnvironmentPrefix - : undefined - ); + const { iModelFavorites, addIModelToFavorites, removeIModelFromFavorites } = + useIModelFavorites( + iTwinId, + accessToken, + serverEnvironmentPrefix === "dev" || serverEnvironmentPrefix === "qa" + ? serverEnvironmentPrefix + : undefined + ); const value = React.useMemo( () => ({ favorites: iModelFavorites, add: addIModelToFavorites, remove: removeIModelFromFavorites, - _shouldRefetch: shouldRefetchFavorites, - _resetShouldRefetch: resetShouldRefetchFavorites, }), - [ - iModelFavorites, - addIModelToFavorites, - removeIModelFromFavorites, - shouldRefetchFavorites, - resetShouldRefetchFavorites, - ] + [iModelFavorites, addIModelToFavorites, removeIModelFromFavorites] ); return ( From 05ad731d4a7bd60fedca712a99513517dfdaf814 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 15:06:47 -0400 Subject: [PATCH 03/34] fix: ensure iTwinId is validated before fetching iModel favorites --- .../src/containers/iModelGrid/useIIModelFavorites.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts index 0354fe86..f33c354c 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -116,7 +116,7 @@ export const useIModelFavorites = ( */ const getIModelFavorites = useCallback( async (abortSignal?: AbortSignal): Promise => { - if (!accessToken) { + if (!accessToken || !iTwinId) { return []; } const url = `${_getAPIServer( From 9f9781ff411499b30f4b4ca06f7e30ff4295a4c4 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 15:19:26 -0400 Subject: [PATCH 04/34] Fix storybook not applying correct theme on first render --- packages/apps/storybook/.storybook/preview.js | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/apps/storybook/.storybook/preview.js b/packages/apps/storybook/.storybook/preview.js index 55c082a4..099eb717 100644 --- a/packages/apps/storybook/.storybook/preview.js +++ b/packages/apps/storybook/.storybook/preview.js @@ -4,14 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import React from "react"; import { ThemeProvider } from "@itwin/itwinui-react"; -import addons from "@storybook/addons"; import { themes } from "@storybook/theming"; - +import { useDarkMode } from "storybook-dark-mode"; import { darkTheme, lightTheme } from "./itwinTheme"; -import '@itwin/itwinui-react/styles.css'; - -// get an instance to the communication channel for the manager and preview -const channel = addons.getChannel(); +import "@itwin/itwinui-react/styles.css"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -31,24 +27,15 @@ export const parameters = { }, }; -const useTheme = () => { - const [dark, setDark] = React.useState(false); - - React.useEffect(() => { - channel.on("DARK_MODE", setDark); - }, []); - - return dark ? "dark" : "light"; -} - export const decorators = [ (Story) => { - const theme = useTheme(); + const isDark = useDarkMode(); + const theme = isDark ? "dark" : "light"; return ( - - - + + + ); }, ]; From f7356d9b396f760fd4e6a6e5e80cbb1ca333d45d Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 15:22:12 -0400 Subject: [PATCH 05/34] fix: rename internalOnClick to handleClickAndAddToRecents for clarity and consistency --- .../src/containers/iModelTiles/IModelTile.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index b7f5740a..3b799306 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -104,7 +104,7 @@ export const IModelTile = ({ } : undefined; - const internalOnClick = React.useCallback( + const handleClickAndAddToRecents = React.useCallback( async (e: React.MouseEvent) => { if (favoritesContext) { try { @@ -157,7 +157,7 @@ export const IModelTile = ({ @@ -212,7 +212,7 @@ export const IModelTile = ({ From fd3fd6eff0d793a42ad563c11e2fe10a2b82bac2 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 15:32:48 -0400 Subject: [PATCH 06/34] revert changes to storybook and create a separate PR for it --- common/config/rush/pnpm-lock.yaml | 49 ++----------------- packages/apps/storybook/.storybook/preview.js | 27 +++++++--- packages/apps/storybook/package.json | 2 +- 3 files changed, 26 insertions(+), 52 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 2bba42fb..27f125a7 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -21,7 +21,7 @@ importers: '@itwin/delete-itwin-react': workspace:* '@itwin/imodel-browser-react': workspace:* '@itwin/itwinui-icons-react': 2.10.0 - '@itwin/itwinui-react': 3.19.4 + '@itwin/itwinui-react': 3.18.0 '@itwin/manage-versions-react': workspace:* '@itwin/storybook-auth-addon': workspace:* '@storybook/addon-actions': ^6.5.16 @@ -66,7 +66,7 @@ importers: '@itwin/delete-itwin-react': link:../../modules/delete-itwin '@itwin/imodel-browser-react': link:../../modules/imodel-browser '@itwin/itwinui-icons-react': 2.10.0_nnrd3gsncyragczmpvfhocinkq - '@itwin/itwinui-react': 3.19.4_nnrd3gsncyragczmpvfhocinkq + '@itwin/itwinui-react': 3.18.0_nnrd3gsncyragczmpvfhocinkq '@itwin/manage-versions-react': link:../../modules/manage-versions '@itwin/storybook-auth-addon': link:../../modules/storybook-auth-addon '@storybook/addon-actions': 6.5.16_nnrd3gsncyragczmpvfhocinkq @@ -2726,20 +2726,6 @@ packages: react: 18.3.1 react-dom: 18.3.1_react@18.3.1 tabbable: 6.2.0 - dev: true - - /@floating-ui/react/0.27.6_nnrd3gsncyragczmpvfhocinkq: - resolution: {integrity: sha512-9GLOPbW8jTeboR2ar9uMMUDUZjpTLscUvOjNvRw2EgppgoLHLUh/P/OW9evULosnvDjhYf2Gwk/gMOP9KvXD2A==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - dependencies: - '@floating-ui/react-dom': 2.1.6_nnrd3gsncyragczmpvfhocinkq - '@floating-ui/utils': 0.2.10 - react: 18.3.1 - react-dom: 18.3.1_react@18.3.1 - tabbable: 6.2.0 - dev: false /@floating-ui/utils/0.2.10: resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} @@ -2857,15 +2843,14 @@ packages: react: '>=17.0.0' react-dom: '>=17.0.0' dependencies: - '@floating-ui/react': 0.27.6_nnrd3gsncyragczmpvfhocinkq + '@floating-ui/react': 0.27.16_nnrd3gsncyragczmpvfhocinkq '@itwin/itwinui-illustrations-react': 2.1.0_nnrd3gsncyragczmpvfhocinkq - '@swc/helpers': 0.5.15 - '@tanstack/react-virtual': 3.13.6_nnrd3gsncyragczmpvfhocinkq + '@swc/helpers': 0.5.17 + '@tanstack/react-virtual': 3.13.12_nnrd3gsncyragczmpvfhocinkq classnames: 2.5.1 react: 18.3.1 react-dom: 18.3.1_react@18.3.1 react-table: 7.8.0_react@18.3.1 - dev: false /@itwin/itwinui-react/3.19.4_nnrd3gsncyragczmpvfhocinkq: resolution: {integrity: sha512-DEKmeMyW2w3DDyzt1KHrfXmyj1A0DkTbgF7R+PvrQrkDVh5+08vnAV/Q+EK9fkKUOuRL8P0lvhMhIPhRa1l1eQ==} @@ -5310,17 +5295,10 @@ packages: - supports-color dev: true - /@swc/helpers/0.5.15: - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - dependencies: - tslib: 2.8.1 - dev: false - /@swc/helpers/0.5.17: resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} dependencies: tslib: 2.8.1 - dev: true /@tanstack/react-virtual/3.13.12_nnrd3gsncyragczmpvfhocinkq: resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} @@ -5331,26 +5309,9 @@ packages: '@tanstack/virtual-core': 3.13.12 react: 18.3.1 react-dom: 18.3.1_react@18.3.1 - dev: true - - /@tanstack/react-virtual/3.13.6_nnrd3gsncyragczmpvfhocinkq: - resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - dependencies: - '@tanstack/virtual-core': 3.13.6 - react: 18.3.1 - react-dom: 18.3.1_react@18.3.1 - dev: false /@tanstack/virtual-core/3.13.12: resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} - dev: true - - /@tanstack/virtual-core/3.13.6: - resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} - dev: false /@testing-library/dom/10.4.0: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} diff --git a/packages/apps/storybook/.storybook/preview.js b/packages/apps/storybook/.storybook/preview.js index 099eb717..55c082a4 100644 --- a/packages/apps/storybook/.storybook/preview.js +++ b/packages/apps/storybook/.storybook/preview.js @@ -4,10 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import React from "react"; import { ThemeProvider } from "@itwin/itwinui-react"; +import addons from "@storybook/addons"; import { themes } from "@storybook/theming"; -import { useDarkMode } from "storybook-dark-mode"; + import { darkTheme, lightTheme } from "./itwinTheme"; -import "@itwin/itwinui-react/styles.css"; +import '@itwin/itwinui-react/styles.css'; + +// get an instance to the communication channel for the manager and preview +const channel = addons.getChannel(); export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -27,15 +31,24 @@ export const parameters = { }, }; +const useTheme = () => { + const [dark, setDark] = React.useState(false); + + React.useEffect(() => { + channel.on("DARK_MODE", setDark); + }, []); + + return dark ? "dark" : "light"; +} + export const decorators = [ (Story) => { - const isDark = useDarkMode(); - const theme = isDark ? "dark" : "light"; + const theme = useTheme(); return ( - - - + + + ); }, ]; diff --git a/packages/apps/storybook/package.json b/packages/apps/storybook/package.json index b81808a3..ba39d9ff 100644 --- a/packages/apps/storybook/package.json +++ b/packages/apps/storybook/package.json @@ -14,7 +14,7 @@ "@itwin/delete-itwin-react": "workspace:*", "@itwin/imodel-browser-react": "workspace:*", "@itwin/itwinui-icons-react": "2.10.0", - "@itwin/itwinui-react": "3.19.4", + "@itwin/itwinui-react": "3.18.0", "@itwin/manage-versions-react": "workspace:*", "@itwin/storybook-auth-addon": "workspace:*", "@storybook/addon-actions": "^6.5.16", From 85b5bd4ab4c9d2d85be5d4267e5b4994c8540024 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 15:51:52 -0400 Subject: [PATCH 07/34] fix: update no iTwin/iModel messages for clarity based on request type --- .../src/containers/ITwinGrid/ITwinGrid.tsx | 7 ++++++- .../src/containers/iModelGrid/IModelGrid.tsx | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index 7128c0b3..ae852df6 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -149,7 +149,12 @@ export const ITwinGrid = ({ tableLoadingData: "Loading...", trialBadge: "Trial", inactiveBadge: "Inactive", - noITwins: "No iTwin found.", + noITwins: + requestType === "recents" + ? "There are no recent iTwins." + : requestType === "favorites" + ? "There are no favorite iTwins." + : "There are no iTwins in this account.", noAuthentication: "No access token provided", error: "An error occurred", addToFavorites: "Add to favorites", diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index ad8eeba7..c00e296b 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -163,7 +163,12 @@ const IModelGridInner = ({ noIModelSearch: "No results found", noIModelSearchSubtext: "Try adjusting your search by using fewer or more general terms.", - noIModels: "There are no iModels in this iTwin.", + noIModels: + requestType === "recents" + ? "There are no recent iModels." + : requestType === "favorites" + ? "There are no favorite iModels." + : "There are no iModels in this iTwin.", noContext: "No context provided", noAuthentication: "No access token provided", error: "An error occurred", @@ -358,7 +363,7 @@ const IModelGridInner = ({ ); } From d7bfc4e41513cce5a9149bc2a497e205a82ada8b Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 15:56:03 -0400 Subject: [PATCH 08/34] fix: allow iTwinId to be optional in IModelFavoritesProvider and useIModelFavorites --- .../imodel-browser/src/containers/iModelGrid/IModelGrid.tsx | 2 +- .../src/containers/iModelGrid/useIIModelFavorites.ts | 6 +++--- .../imodel-browser/src/contexts/IModelFavoritesContext.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index c00e296b..63278b4d 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -105,7 +105,7 @@ export interface IModelGridProps { export const IModelGrid = (props: IModelGridProps) => { return ( diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts index f33c354c..c9eaf2e7 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -20,13 +20,13 @@ const HOOK_ABORT_ERROR = * - {function} removeIModelFromFavorites - A function to remove an iModel from favorites. */ export const useIModelFavorites = ( - iTwinId: string, + iTwinId: string | undefined, accessToken: string | (() => Promise) | undefined, serverEnvironmentPrefix?: "dev" | "qa" | "" ): { iModelFavorites: Set; - addIModelToFavorites: (iTwinId: string) => Promise; - removeIModelFromFavorites: (iTwinId: string) => Promise; + addIModelToFavorites: (iModelId: string) => Promise; + removeIModelFromFavorites: (iModelId: string) => Promise; } => { const [iModelFavorites, setIModelFavorites] = useState(new Set()); diff --git a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx index 9a358427..f9c2c7f3 100644 --- a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx +++ b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx @@ -17,7 +17,7 @@ export const IModelFavoritesContext = React.createContext< >(undefined); export interface IModelFavoritesProviderProps { - iTwinId: string; + iTwinId: string | undefined; accessToken?: string | (() => Promise); serverEnvironmentPrefix?: string; children: React.ReactNode; From 392eaae959a682c2c9e71a210bbd1d85e89c0111 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 16:05:02 -0400 Subject: [PATCH 09/34] feat: add iModel recents and favorites functionality --- .../omar-imodel-recent_2025-10-16-20-04.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@itwin/imodel-browser-react/omar-imodel-recent_2025-10-16-20-04.json diff --git a/common/changes/@itwin/imodel-browser-react/omar-imodel-recent_2025-10-16-20-04.json b/common/changes/@itwin/imodel-browser-react/omar-imodel-recent_2025-10-16-20-04.json new file mode 100644 index 00000000..a8d89d0d --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/omar-imodel-recent_2025-10-16-20-04.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Feat: Add iModel recents and favorites functionality", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} \ No newline at end of file From b014a5581d253fa23a4ca4629297f37cbf3b3d27 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 16 Oct 2025 16:14:37 -0400 Subject: [PATCH 10/34] fix: improve clarity of no iTwin messages for recents and favorites --- .../imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index ae852df6..f1be1908 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -151,10 +151,10 @@ export const ITwinGrid = ({ inactiveBadge: "Inactive", noITwins: requestType === "recents" - ? "There are no recent iTwins." + ? "No recent iTwins." : requestType === "favorites" - ? "There are no favorite iTwins." - : "There are no iTwins in this account.", + ? "No favorite iTwins." + : "No iTwin found.", noAuthentication: "No access token provided", error: "An error occurred", addToFavorites: "Add to favorites", From f5155c09085f77f2cba2e6f12db163eacfb3aeb4 Mon Sep 17 00:00:00 2001 From: Omar H Date: Wed, 22 Oct 2025 17:12:24 -0400 Subject: [PATCH 11/34] Update packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/containers/iModelGrid/useIModelTableConfig.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx index 6850ca37..9c10bb12 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelTableConfig.tsx @@ -68,8 +68,8 @@ export const useIModelTableConfig = ({ styleType="borderless" aria-label={ isFavorite - ? strings.addToFavorites - : strings.removeFromFavorites + ? strings.removeFromFavorites + : strings.addToFavorites } onClick={async (e) => { e.stopPropagation(); From c8155b7ddeac84ec3ce9e61e476cfe89474df3e3 Mon Sep 17 00:00:00 2001 From: Omar H Date: Wed, 22 Oct 2025 17:13:47 -0400 Subject: [PATCH 12/34] Update packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/containers/iModelGrid/useIIModelFavorites.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts index c9eaf2e7..6a193443 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -93,7 +93,7 @@ export const useIModelFavorites = ( }); if (!result || (result.status !== 200 && result.status !== 204)) { - throw new Error(`Failed to remove iModel ${iModelId} to favorites`); + throw new Error(`Failed to remove iModel ${iModelId} from favorites`); } setIModelFavorites((prev) => { From 0789c867889b06bdfeea704d176db407c773953a Mon Sep 17 00:00:00 2001 From: Omar H Date: Fri, 24 Oct 2025 11:30:05 -0400 Subject: [PATCH 13/34] Update packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts Co-authored-by: Simi Hartstein <6485888+simihartstein@users.noreply.github.com> --- .../src/containers/iModelGrid/useIIModelFavorites.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts index 6a193443..0d03fa26 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -8,7 +8,7 @@ import { useCallback, useEffect, useState } from "react"; import { _getAPIServer } from "../../utils/_apiOverrides"; const HOOK_ABORT_ERROR = - "The fetch request was aborted by the cleanup function."; + "iModel favorites request was aborted by the cleanup function."; /** * Custom hook to manage iModel favorites. From 0d8577e0e536a9494b3e1b43113b9978063c55f8 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 11:44:49 -0400 Subject: [PATCH 14/34] Add comment for requestType --- .../imodel-browser/src/containers/iModelGrid/IModelGrid.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index 63278b4d..2d73e82f 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -31,6 +31,7 @@ export interface IModelGridProps { accessToken?: string | (() => Promise) | undefined; /** ITwin Id to list the iModels from (mutually exclusive to assetId) */ iTwinId?: string | undefined; + /** Type of iModels to request - "favorites" for user's favorite iModels, "recents" for recently accessed iModels, or empty string for all iModels */ requestType?: "favorites" | "recents" | ""; /** Thumbnail click handler. */ onThumbnailClick?(iModel: IModelFull): void; From 3ee47aaf4ec7d0de2fbd478441b3665793707a35 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 11:45:31 -0400 Subject: [PATCH 15/34] Refactor iModel recents handling by introducing addIModelToRecents utility function and updating IModelGrid and IModelTile components to use it --- .../src/containers/iModelGrid/IModelGrid.tsx | 20 ++--- .../src/containers/iModelTiles/IModelTile.tsx | 31 +++----- .../imodel-browser/src/utils/imodelApi.ts | 77 +++++++++++++++++++ 3 files changed, 92 insertions(+), 36 deletions(-) create mode 100644 packages/modules/imodel-browser/src/utils/imodelApi.ts diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index 2d73e82f..d31e4c80 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -17,8 +17,9 @@ import { IModelSortOptions, ViewType, } from "../../types"; -import { _getAPIServer, _mergeStrings } from "../../utils/_apiOverrides"; +import { _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; +import { addIModelToRecents } from "../../utils/imodelApi"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; import styles from "./IModelGrid.module.scss"; @@ -221,19 +222,10 @@ const IModelGridInner = ({ return; } - const url = `${_getAPIServer( - apiOverrides?.serverEnvironmentPrefix - )}/imodels/recents/${encodeURIComponent(iModel.id)}`; - - void fetch(url, { - method: "POST", - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, + void addIModelToRecents({ + iModelId: iModel.id, + accessToken, + serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, }); } catch (e) { // swallow errors to avoid disrupting the UI diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 3b799306..5a913ad8 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -13,6 +13,7 @@ import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; +import { addIModelToRecents } from "../../utils/imodelApi"; import { IModelThumbnail } from "../iModelThumbnail/IModelThumbnail"; type TileProps = React.ComponentPropsWithoutRef; @@ -107,29 +108,15 @@ export const IModelTile = ({ const handleClickAndAddToRecents = React.useCallback( async (e: React.MouseEvent) => { if (favoritesContext) { - try { - if (!accessToken) { - return; - } - - const url = `${_getAPIServer( - apiOverrides?.serverEnvironmentPrefix - )}/imodels/recents/${encodeURIComponent(iModel.id)}`; - - void fetch(url, { - method: "POST", - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, - }); - } catch (e) { - // swallow errors to avoid disrupting the UI - console.error("Failed to add iModel to recents", e); + if (!accessToken) { + return; } + + void addIModelToRecents({ + iModelId: iModel.id, + accessToken, + serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, + }); } onClick?.(e) ?? onThumbnailClick?.(iModel); }, diff --git a/packages/modules/imodel-browser/src/utils/imodelApi.ts b/packages/modules/imodel-browser/src/utils/imodelApi.ts new file mode 100644 index 00000000..a764e753 --- /dev/null +++ b/packages/modules/imodel-browser/src/utils/imodelApi.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +/*--------------------------------------------------------------------------------------------- + * Utility functions for iModel related API operations. + *--------------------------------------------------------------------------------------------*/ +import { _getAPIServer } from "./_apiOverrides"; + +export type AccessTokenProvider = string | (() => Promise) | undefined; + +export async function addIModelToRecents(options: { + iModelId: string; + accessToken?: AccessTokenProvider; + serverEnvironmentPrefix?: "dev" | "qa" | ""; +}): Promise { + const { iModelId, accessToken, serverEnvironmentPrefix } = options; + try { + if (!accessToken) { + return; + } + + const token = + typeof accessToken === "function" ? await accessToken() : accessToken; + + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/recents/${encodeURIComponent(iModelId)}`; + + // fire-and-forget POST to record recents; swallow errors so UI isn't disrupted + void fetch(url, { + method: "POST", + headers: { + authorization: token as string, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + } catch (e) { + // keep parity with previous behavior where errors were swallowed + // Log for diagnostics + // eslint-disable-next-line no-console + console.error("Failed to add iModel to recents", e); + } +} + +export async function removeIModelFromRecents(options: { + iModelId: string; + accessToken?: AccessTokenProvider; + serverEnvironmentPrefix?: "dev" | "qa" | ""; +}): Promise { + const { iModelId, accessToken, serverEnvironmentPrefix } = options; + try { + if (!accessToken) { + return; + } + + const token = + typeof accessToken === "function" ? await accessToken() : accessToken; + + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/recents/${encodeURIComponent(iModelId)}`; + + void fetch(url, { + method: "DELETE", + headers: { + authorization: token as string, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + } catch (e) { + // keep parity with previous behavior where errors were swallowed + // Log for diagnostics + // eslint-disable-next-line no-console + console.error("Failed to remove iModel from recents", e); + } +} From 30a892570e08119aeae4a9c3c1566e1c068f1c7a Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 11:46:46 -0400 Subject: [PATCH 16/34] fix naming of file useIModelFavorites --- ...tes.test.ts => useIModelFavorites.test.ts} | 7 +- .../iModelGrid/useIModelFavorites.ts | 207 ++++++++++++++++++ .../src/contexts/IModelFavoritesContext.tsx | 2 +- 3 files changed, 211 insertions(+), 5 deletions(-) rename packages/modules/imodel-browser/src/containers/iModelGrid/{useIIModelFavorites.test.ts => useIModelFavorites.test.ts} (93%) create mode 100644 packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.test.ts similarity index 93% rename from packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts rename to packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.test.ts index b48ffb13..4c56c934 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.test.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.test.ts @@ -6,10 +6,10 @@ import { renderHook } from "@testing-library/react-hooks"; import { act } from "react"; -import { useIModelFavorites } from "./useIIModelFavorites"; +import { useIModelFavorites } from "./useIModelFavorites"; export function mockFetch(data: any, status = 200) { - return jest.fn().mockImplementationOnce(async () => + return jest.fn().mockImplementation(async () => Promise.resolve({ status, ok: true, @@ -66,18 +66,17 @@ describe("useIModelFavorites", () => { }); test("should add and remove an iModel from favorites", async () => { + window.fetch = mockFetch({}); const { result } = renderHook(() => useIModelFavorites(iTwinId, accessToken) ); const iModelId = "test-iModel-id"; await act(async () => { - window.fetch = mockFetch({}); await result.current.addIModelToFavorites(iModelId); }); expect(result.current.iModelFavorites.has(iModelId)).toBe(true); await act(async () => { - window.fetch = mockFetch({}); await result.current.removeIModelFromFavorites(iModelId); }); expect(result.current.iModelFavorites.has(iModelId)).toBe(false); diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts new file mode 100644 index 00000000..6a193443 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ + +import { useCallback, useEffect, useState } from "react"; + +import { _getAPIServer } from "../../utils/_apiOverrides"; + +const HOOK_ABORT_ERROR = + "The fetch request was aborted by the cleanup function."; + +/** + * Custom hook to manage iModel favorites. + * @param {string | (() => Promise) | undefined} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param {ApiOverrides} [apiOverrides] - Optional API overrides. + * @returns {object} - An object containing: + * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. + * - {function} addIModelToFavorites - A function to add an iModel to favorites. + * - {function} removeIModelFromFavorites - A function to remove an iModel from favorites. + */ +export const useIModelFavorites = ( + iTwinId: string | undefined, + accessToken: string | (() => Promise) | undefined, + serverEnvironmentPrefix?: "dev" | "qa" | "" +): { + iModelFavorites: Set; + addIModelToFavorites: (iModelId: string) => Promise; + removeIModelFromFavorites: (iModelId: string) => Promise; +} => { + const [iModelFavorites, setIModelFavorites] = useState(new Set()); + + /** + * Adds an iModel to the favorites. + * @param {string} iModelId - The ID of the iModel to add to favorites. + * @returns {Promise} + */ + const addIModelToFavorites = useCallback( + async (iModelId: string): Promise => { + if (!accessToken || !iModelId || iModelId === "") { + return; + } + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites/${iModelId}`; + try { + const result = await fetch(url, { + method: "PUT", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + + if (!result || (result.status !== 200 && result.status !== 204)) { + throw new Error(`Failed to add iModel ${iModelId} to favorites`); + } + + setIModelFavorites((prev) => new Set([...prev, iModelId])); + } catch (error) { + console.error(error); + } + }, + [accessToken, serverEnvironmentPrefix] + ); + + /** + * Removes an iModel from the favorites. + * @param {string} iModelId - The ID of the iModel to remove from favorites. + * @returns {Promise} + */ + const removeIModelFromFavorites = useCallback( + async (iModelId: string): Promise => { + if (!accessToken || !iModelId || iModelId === "") { + return; + } + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites/${iModelId}`; + try { + const result = await fetch(url, { + method: "DELETE", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + + if (!result || (result.status !== 200 && result.status !== 204)) { + throw new Error(`Failed to remove iModel ${iModelId} from favorites`); + } + + setIModelFavorites((prev) => { + const newFavorites = new Set(prev); + newFavorites.delete(iModelId); + return newFavorites; + }); + } catch (error) { + console.error(error); + } + }, + [accessToken, serverEnvironmentPrefix] + ); + + /** + * Fetches iTwin favorites from the API. + * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. + * @returns {Promise} - A promise that resolves to an array of iTwin favorites. + * @throws {Error} - Throws an error if the fetch request fails. + */ + const getIModelFavorites = useCallback( + async (abortSignal?: AbortSignal): Promise => { + if (!accessToken || !iTwinId) { + return []; + } + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites?iTwinId=${iTwinId}`; + const result = await fetch(url, { + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + signal: abortSignal, + }); + if (abortSignal?.aborted) { + throw new Error(HOOK_ABORT_ERROR); + } + if (!result) { + throw new Error( + `Failed to fetch iModels favorites from ${url}.\nNo response.` + ); + } + if (result.status !== 200) { + throw new Error( + `Failed to fetch iModels favorites from ${url}.\nStatus: ${result.status}` + ); + } + const response: IModelFavoritesResponse = await result.json(); + return response.iModels; + }, + [accessToken, iTwinId, serverEnvironmentPrefix] + ); + + useEffect(() => { + const controller = new AbortController(); + /** + * Fetches iTwin favorites and updates the state. + * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. + */ + const fetchIModelFavorites = async (abortSignal?: AbortSignal) => { + try { + const favorites = await getIModelFavorites(abortSignal); + setIModelFavorites(new Set(favorites.map((favorite) => favorite.id))); + } catch (error) { + if ( + error === HOOK_ABORT_ERROR || + (error instanceof Error && error.name === "AbortError") + ) { + return; + } + console.error(error); + } + }; + void fetchIModelFavorites(controller.signal); + + return () => { + controller.abort(); + }; + }, [getIModelFavorites]); + + return { + iModelFavorites, + addIModelToFavorites, + removeIModelFromFavorites, + }; +}; + +/** Response from https://developer.bentley.com/apis/imodels-v2/operations/get-my-favorite-imodels/ */ +interface IModelFavoritesResponse { + iModels: IModelFavorites[]; + _links: { + self: { + href: string; + }; + prev?: { + href: string; + }; + next?: { + href: string; + }; + }; +} +interface IModelFavorites { + id: string; + displayName: string; + dataCenterLocation: string; +} diff --git a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx index f9c2c7f3..ffef8915 100644 --- a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx +++ b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import React from "react"; -import { useIModelFavorites } from "../containers/iModelGrid/useIIModelFavorites"; +import { useIModelFavorites } from "../containers/iModelGrid/useIModelFavorites"; export interface IModelFavoritesContextValue { favorites: Set; From dc5ac40e45d2bc57596c1d898999376b428e9469 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 12:48:24 -0400 Subject: [PATCH 17/34] Refactor access token handling to use AccessTokenProvider type across iModel and ITwin components --- .../src/containers/ITwinGrid/ITwinGrid.tsx | 3 +- .../src/containers/ITwinGrid/useITwinData.ts | 3 +- .../containers/ITwinGrid/useITwinFavorites.ts | 5 ++-- .../src/containers/iModelGrid/IModelGrid.tsx | 3 +- .../iModelGrid/useIIModelFavorites.ts | 5 ++-- .../containers/iModelGrid/useIModelData.ts | 5 ++-- .../iModelGrid/useIModelFavorites.ts | 30 +++++-------------- .../iModelThumbnail/IModelThumbnail.tsx | 4 +-- .../iModelThumbnail/useIModelThumbnail.ts | 4 +-- .../src/containers/iModelTiles/IModelTile.tsx | 6 ++-- .../src/contexts/IModelFavoritesContext.tsx | 3 +- packages/modules/imodel-browser/src/types.ts | 2 ++ .../imodel-browser/src/utils/imodelApi.ts | 22 +++++++++++++- 13 files changed, 54 insertions(+), 41 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index f1be1908..a3dbc78e 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -11,6 +11,7 @@ import { InView } from "react-intersection-observer"; import { GridStructure } from "../../components/gridStructure/GridStructure"; import { NoResults } from "../../components/noResults/NoResults"; import { + AccessTokenProvider, ApiOverrides, DataStatus, ITwinCellOverrides, @@ -63,7 +64,7 @@ export interface ITwinGridStrings { export interface ITwinGridProps { /** Access token that requires the `itwins:read` scope. Provide a function that returns the token to prevent the token from expiring. Function must be memoized. */ - accessToken?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; /** Type of iTwin to request */ requestType?: "favorites" | "recents" | ""; /** Sub class of iTwin, defaults to Project */ diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index 5be1b20a..06ba641a 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -5,6 +5,7 @@ import React from "react"; import { + AccessTokenProvider, ApiOverrides, DataStatus, ITwinFilterOptions, @@ -17,7 +18,7 @@ import { useITwinFilter } from "./useITwinFilter"; export interface ProjectDataHookOptions { requestType?: "favorites" | "recents" | ""; iTwinSubClass?: ITwinSubClass; - accessToken?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; apiOverrides?: ApiOverrides; filterOptions?: ITwinFilterOptions; orderbyOptions?: string; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts index 5aab02ce..017d596b 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react"; +import { AccessTokenProvider } from "../../types"; import { _getAPIServer } from "../../utils/_apiOverrides"; const HOOK_ABORT_ERROR = @@ -12,7 +13,7 @@ const HOOK_ABORT_ERROR = /** * Custom hook to manage iTwin favorites. - * @param {string | (() => Promise) | undefined} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param {AccessTokenProvider} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. * @param {ApiOverrides} [apiOverrides] - Optional API overrides. * @returns {object} - An object containing: * - {Set} iTwinFavorites - A set of iTwin IDs that are marked as favorites. @@ -22,7 +23,7 @@ const HOOK_ABORT_ERROR = * - {function} resetShouldRefetchFavorites - A function to reset shouldRefetchFavorites back to false. */ export const useITwinFavorites = ( - accessToken: string | (() => Promise) | undefined, + accessToken: AccessTokenProvider | undefined, serverEnvironmentPrefix?: "dev" | "qa" | "" ): { iTwinFavorites: Set; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index d31e4c80..9028a7fb 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -10,6 +10,7 @@ import { GridStructure } from "../../components/gridStructure/GridStructure"; import { NoResults } from "../../components/noResults/NoResults"; import { IModelFavoritesProvider } from "../../contexts/IModelFavoritesContext"; import { + AccessTokenProvider, ApiOverrides, DataStatus, IModelCellOverrides, @@ -29,7 +30,7 @@ import { useIModelTableConfig } from "./useIModelTableConfig"; export interface IModelGridProps { /** * Access token that requires the `imodels:read` scope. Provide a function that returns the token to prevent the token from expiring. Function must be memoized. */ - accessToken?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; /** ITwin Id to list the iModels from (mutually exclusive to assetId) */ iTwinId?: string | undefined; /** Type of iModels to request - "favorites" for user's favorite iModels, "recents" for recently accessed iModels, or empty string for all iModels */ diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts index 0d03fa26..f6aa39ce 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react"; +import { AccessTokenProvider } from "../../types"; import { _getAPIServer } from "../../utils/_apiOverrides"; const HOOK_ABORT_ERROR = @@ -12,7 +13,7 @@ const HOOK_ABORT_ERROR = /** * Custom hook to manage iModel favorites. - * @param {string | (() => Promise) | undefined} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param {AccessTokenProvider} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. * @param {ApiOverrides} [apiOverrides] - Optional API overrides. * @returns {object} - An object containing: * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. @@ -21,7 +22,7 @@ const HOOK_ABORT_ERROR = */ export const useIModelFavorites = ( iTwinId: string | undefined, - accessToken: string | (() => Promise) | undefined, + accessToken: AccessTokenProvider, serverEnvironmentPrefix?: "dev" | "qa" | "" ): { iModelFavorites: Set; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index 4fd37b05..27397473 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts @@ -5,6 +5,7 @@ import React, { useEffect } from "react"; import { + AccessTokenProvider, ApiOverrides, DataStatus, IModelFull, @@ -17,7 +18,7 @@ import { useIModelSort } from "./useIModelSort"; export interface IModelDataHookOptions { requestType?: "favorites" | "recents" | ""; iTwinId?: string | undefined; - accessToken?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; sortOptions?: IModelSortOptions; apiOverrides?: ApiOverrides; searchText?: string | undefined; @@ -206,7 +207,7 @@ export const useIModelData = ({ const createFetchIModelsFn = ( iTwinId: string, - accessToken: string | (() => Promise), + accessToken: AccessTokenProvider, sortType: string | undefined, sortDescending: boolean, page: number, diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts index 6a193443..b9a1ee85 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts @@ -5,14 +5,19 @@ import { useCallback, useEffect, useState } from "react"; +import { AccessTokenProvider } from "../../types"; import { _getAPIServer } from "../../utils/_apiOverrides"; +import { + IModelFavorites, + IModelFavoritesResponse, +} from "../../utils/imodelApi"; const HOOK_ABORT_ERROR = "The fetch request was aborted by the cleanup function."; /** * Custom hook to manage iModel favorites. - * @param {string | (() => Promise) | undefined} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param {AccessTokenProvider} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. * @param {ApiOverrides} [apiOverrides] - Optional API overrides. * @returns {object} - An object containing: * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. @@ -21,7 +26,7 @@ const HOOK_ABORT_ERROR = */ export const useIModelFavorites = ( iTwinId: string | undefined, - accessToken: string | (() => Promise) | undefined, + accessToken: AccessTokenProvider | undefined, serverEnvironmentPrefix?: "dev" | "qa" | "" ): { iModelFavorites: Set; @@ -184,24 +189,3 @@ export const useIModelFavorites = ( removeIModelFromFavorites, }; }; - -/** Response from https://developer.bentley.com/apis/imodels-v2/operations/get-my-favorite-imodels/ */ -interface IModelFavoritesResponse { - iModels: IModelFavorites[]; - _links: { - self: { - href: string; - }; - prev?: { - href: string; - }; - next?: { - href: string; - }; - }; -} -interface IModelFavorites { - id: string; - displayName: string; - dataCenterLocation: string; -} diff --git a/packages/modules/imodel-browser/src/containers/iModelThumbnail/IModelThumbnail.tsx b/packages/modules/imodel-browser/src/containers/iModelThumbnail/IModelThumbnail.tsx index 34e1d24a..cda73408 100644 --- a/packages/modules/imodel-browser/src/containers/iModelThumbnail/IModelThumbnail.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelThumbnail/IModelThumbnail.tsx @@ -8,7 +8,7 @@ import classNames from "classnames"; import React from "react"; import { useInView } from "react-intersection-observer"; -import { ApiOverrides } from "../../types"; +import { AccessTokenProvider, ApiOverrides } from "../../types"; import { useIModelThumbnail } from "./useIModelThumbnail"; export interface IModelThumbnailProps { @@ -18,7 +18,7 @@ export interface IModelThumbnailProps { /** Triggered on the image click, controls pointer */ onClick?(iModelId: string): void; /* Access token that requires the `imodels:read` scope. */ - accessToken?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; /** Object that configures different overrides for the API * @property data thumbnail URL * @property serverEnvironmentPrefix Either qa or dev diff --git a/packages/modules/imodel-browser/src/containers/iModelThumbnail/useIModelThumbnail.ts b/packages/modules/imodel-browser/src/containers/iModelThumbnail/useIModelThumbnail.ts index f82613e5..1d9abaab 100644 --- a/packages/modules/imodel-browser/src/containers/iModelThumbnail/useIModelThumbnail.ts +++ b/packages/modules/imodel-browser/src/containers/iModelThumbnail/useIModelThumbnail.ts @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import defaultIModelThumbnail from "../../images/default-thumbnail.png"; -import { ApiOverrides } from "../../types"; +import { AccessTokenProvider, ApiOverrides } from "../../types"; import { _getAPIServer } from "../../utils/_apiOverrides"; /** Convert buffer response to URL format: data:image/png;base64 */ @@ -24,7 +24,7 @@ function convertArrayBufferToUrlBase64PNG(buffer: ArrayBuffer) { /** Use cached thumbnail or upload thumbnail from server */ export const useIModelThumbnail = ( iModelId: string, - accessToken?: string | (() => Promise) | undefined, + accessToken?: AccessTokenProvider, apiOverrides?: ApiOverrides ) => { const [thumbnail, setThumbnail] = useState(); diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 5a913ad8..c4d80040 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -7,8 +7,8 @@ import { IconButton, Tile } from "@itwin/itwinui-react"; import React from "react"; import { useIModelFavoritesContext } from "../../contexts/IModelFavoritesContext"; -import { ApiOverrides, IModelFull } from "../../types"; -import { _getAPIServer, _mergeStrings } from "../../utils/_apiOverrides"; +import { AccessTokenProvider, ApiOverrides, IModelFull } from "../../types"; +import { _mergeStrings } from "../../utils/_apiOverrides"; import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, @@ -22,7 +22,7 @@ export interface IModelTileProps { /** iModel to display */ iModel: IModelFull; /** Access token to display */ - accessToken?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; /** List of options to build for the imodel context menu */ iModelOptions?: ContextMenuBuilderItem[]; /** Function to call on thumbnail click */ diff --git a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx index ffef8915..fb6aa6d7 100644 --- a/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx +++ b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx @@ -5,6 +5,7 @@ import React from "react"; import { useIModelFavorites } from "../containers/iModelGrid/useIModelFavorites"; +import { AccessTokenProvider } from "../types"; export interface IModelFavoritesContextValue { favorites: Set; @@ -18,7 +19,7 @@ export const IModelFavoritesContext = React.createContext< export interface IModelFavoritesProviderProps { iTwinId: string | undefined; - accessToken?: string | (() => Promise); + accessToken?: AccessTokenProvider; serverEnvironmentPrefix?: string; children: React.ReactNode; } diff --git a/packages/modules/imodel-browser/src/types.ts b/packages/modules/imodel-browser/src/types.ts index 38e8b66d..3ac86799 100644 --- a/packages/modules/imodel-browser/src/types.ts +++ b/packages/modules/imodel-browser/src/types.ts @@ -118,3 +118,5 @@ export type ITwinCellOverrides = { ITwinName?: (cellData: CellProps) => React.ReactNode; LastModified?: (cellData: CellProps) => React.ReactNode; }; + +export type AccessTokenProvider = string | (() => Promise); diff --git a/packages/modules/imodel-browser/src/utils/imodelApi.ts b/packages/modules/imodel-browser/src/utils/imodelApi.ts index a764e753..20af5fa6 100644 --- a/packages/modules/imodel-browser/src/utils/imodelApi.ts +++ b/packages/modules/imodel-browser/src/utils/imodelApi.ts @@ -5,9 +5,29 @@ /*--------------------------------------------------------------------------------------------- * Utility functions for iModel related API operations. *--------------------------------------------------------------------------------------------*/ +import { AccessTokenProvider } from "../types"; import { _getAPIServer } from "./_apiOverrides"; -export type AccessTokenProvider = string | (() => Promise) | undefined; +/** Response from https://developer.bentley.com/apis/imodels-v2/operations/get-my-favorite-imodels/ */ +export interface IModelFavoritesResponse { + iModels: IModelFavorites[]; + _links: { + self: { + href: string; + }; + prev?: { + href: string; + }; + next?: { + href: string; + }; + }; +} +export interface IModelFavorites { + id: string; + displayName: string; + dataCenterLocation: string; +} export async function addIModelToRecents(options: { iModelId: string; From 49e2053acba9a61fb78c9aec4aefada39d17f653 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 12:49:44 -0400 Subject: [PATCH 18/34] Update sorting logic in useIModelData and useIModelSort to improve handling of recents --- .../imodel-browser/src/containers/iModelGrid/useIModelData.ts | 2 +- .../imodel-browser/src/containers/iModelGrid/useIModelSort.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index 27397473..47a78f56 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts @@ -59,7 +59,7 @@ export const useIModelData = ({ const sortedIModels = useIModelSort( iModels, sortOptions, - requestType === "recents" + requestType !== "recents" ); const sortChanged = sortOptions?.descending !== previousSortOptions?.descending || diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index a5d378a0..946d2b05 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -33,7 +33,7 @@ const sortStringValues = (a: string, b: string) => a.localeCompare(b); export const useIModelSort = ( iModels: IModelFull[], options?: IModelSortOptions, - skipSort = false + enableSort = true ) => { const sortType = typeof options !== "function" ? options?.sortType : undefined; @@ -41,7 +41,7 @@ export const useIModelSort = ( typeof options !== "function" ? options?.descending ?? false : undefined; const sortFn = typeof options === "function" ? options : undefined; return React.useMemo(() => { - if (skipSort) { + if (!enableSort) { return iModels; } if (sortFn) { From 05e862c3e19d08044f2458f8280379e786d5d237 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 12:50:00 -0400 Subject: [PATCH 19/34] Refactor IModelTile to rename onClick prop to tilePropsOnClick for clarity --- .../src/containers/iModelTiles/IModelTile.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index c4d80040..b46e8f68 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -74,7 +74,7 @@ export const IModelTile = ({ buttons, moreOptions, isDisabled, - onClick, + onClick: tilePropsOnClick, metadata, ...rest } = tileProps ?? {}; @@ -118,14 +118,14 @@ export const IModelTile = ({ serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, }); } - onClick?.(e) ?? onThumbnailClick?.(iModel); + tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel); }, [ accessToken, apiOverrides?.serverEnvironmentPrefix, favoritesContext, iModel, - onClick, + tilePropsOnClick, onThumbnailClick, ] ); @@ -164,8 +164,8 @@ export const IModelTile = ({ } onClick={async () => { favoritesContext.favorites.has(iModel.id) - ? await favoritesContext.remove?.(iModel.id) - : await favoritesContext.add?.(iModel.id); + ? await favoritesContext.remove(iModel.id) + : await favoritesContext.add(iModel.id); }} style={{ paddingInline: "var(--iui-button-padding-block)", From b50de1082222eed5e237a5d8e392d2b5518a38f8 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 12:59:02 -0400 Subject: [PATCH 20/34] Fix dependency array in useIModelSort to include enableSort for proper memoization --- .../imodel-browser/src/containers/iModelGrid/useIModelSort.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index 946d2b05..62d95295 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -33,6 +33,7 @@ const sortStringValues = (a: string, b: string) => a.localeCompare(b); export const useIModelSort = ( iModels: IModelFull[], options?: IModelSortOptions, + // needed to account for limitation of react hooks enableSort = true ) => { const sortType = @@ -65,5 +66,5 @@ export const useIModelSort = ( } ); return descending ? sorted.reverse() : sorted; - }, [skipSort, iModels, sortFn, sortType, descending]); + }, [enableSort, sortFn, sortType, iModels, descending]); }; From 22bec736e3582159f40dd798690f43bf71f5c9c4 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 13:21:06 -0400 Subject: [PATCH 21/34] remove unnecessary if condition in handleClick --- .../src/containers/iModelTiles/IModelTile.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index b46e8f68..b2cea9f5 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -107,23 +107,21 @@ export const IModelTile = ({ const handleClickAndAddToRecents = React.useCallback( async (e: React.MouseEvent) => { - if (favoritesContext) { - if (!accessToken) { - return; - } - - void addIModelToRecents({ - iModelId: iModel.id, - accessToken, - serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, - }); + if (!accessToken) { + return; } + + void addIModelToRecents({ + iModelId: iModel.id, + accessToken, + serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, + }); + tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel); }, [ accessToken, apiOverrides?.serverEnvironmentPrefix, - favoritesContext, iModel, tilePropsOnClick, onThumbnailClick, From 83daffd9e8e3ca6398b83211504a2bedc26254a1 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 16:44:02 -0400 Subject: [PATCH 22/34] Add hover state and styling for favorite icon in IModelTile and ITwinTile --- .../TileFavoriteIcon.module.scss | 16 ++++++ .../tileFavoriteIcon/TileFavoriteIcon.tsx | 55 +++++++++++++++++++ .../src/containers/ITwinGrid/ITwinTile.tsx | 38 ++++++------- .../src/containers/iModelGrid/IModelGrid.tsx | 4 +- .../src/containers/iModelTiles/IModelTile.tsx | 40 +++++--------- 5 files changed, 105 insertions(+), 48 deletions(-) create mode 100644 packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.module.scss create mode 100644 packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx diff --git a/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.module.scss b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.module.scss new file mode 100644 index 00000000..98408f61 --- /dev/null +++ b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.module.scss @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +.favoriteIconButton { + padding-inline: var(--iui-button-padding-block); + background-color: rgb(from var(--iui-color-background-hover) r g b / 0.7); + + &:hover { + background-color: var(--iui-color-background-hover); + } + + &:active { + background-color: var(--iui-color-background); + } +} \ No newline at end of file diff --git a/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx new file mode 100644 index 00000000..45d58e1d --- /dev/null +++ b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import { SvgStar, SvgStarHollow } from "@itwin/itwinui-icons-react"; +import { IconButton } from "@itwin/itwinui-react"; +import React from "react"; + +import styles from "./TileFavoriteIcon.module.scss"; + +export interface TileFavoriteIconProps { + /** Whether the item is currently favorited */ + isFavorite: boolean; + /** Callback to add the item to favorites */ + onAddToFavorites: () => Promise; + /** Callback to remove the item from favorites */ + onRemoveFromFavorites: () => Promise; + /** Accessible label for adding to favorites */ + addLabel: string; + /** Accessible label for removing from favorites */ + removeLabel: string; + /** Whether to hide the icon (will not hide if the item is favorited) */ + hide?: boolean; +} + +/** + * Reusable favorite icon button for Tile components + * Shows a star icon that can be clicked to add/remove from favorites + */ +export const TileFavoriteIcon = ({ + isFavorite, + onAddToFavorites, + onRemoveFromFavorites, + addLabel, + removeLabel, + hide, +}: TileFavoriteIconProps) => { + // Don't render if hide is true and not favorited + if (hide && !isFavorite) { + return null; + } + + return ( + { + isFavorite ? await onRemoveFromFavorites() : await onAddToFavorites(); + }} + className={styles.favoriteIconButton} + styleType="borderless" + > + {isFavorite ? : } + + ); +}; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx index 9a19c478..0035529a 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx @@ -2,10 +2,11 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { SvgItwin, SvgStar, SvgStarHollow } from "@itwin/itwinui-icons-react"; -import { Badge, IconButton, ThemeProvider, Tile } from "@itwin/itwinui-react"; +import { SvgItwin } from "@itwin/itwinui-icons-react"; +import { Badge, ThemeProvider, Tile } from "@itwin/itwinui-react"; import React from "react"; +import { TileFavoriteIcon } from "../../components/tileFavoriteIcon/TileFavoriteIcon"; import { ITwinFull } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; import { @@ -81,7 +82,7 @@ export const ITwinTile = ({ onClick, ...rest } = tileProps ?? {}; - + const [isHovered, setIsHovered] = React.useState(false); const strings = _mergeStrings( { trialBadge: "Trial", @@ -112,6 +113,8 @@ export const ITwinTile = ({ status={status} isDisabled={isDisabled} style={fullWidth ? { width: "100%" } : undefined} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} {...rest} > @@ -130,23 +133,18 @@ export const ITwinTile = ({ {leftIcon && {leftIcon}} {rightIcon} - {isFavorite !== undefined && ( - { - isFavorite - ? await removeFromFavorites?.(iTwin.id) - : await addToFavorites?.(iTwin.id); - }} - styleType="borderless" - > - {isFavorite ? : } - - )} + {isFavorite !== undefined && + addToFavorites && + removeFromFavorites && ( + addToFavorites(iTwin.id)} + onRemoveFromFavorites={() => removeFromFavorites(iTwin.id)} + addLabel={strings.addToFavorites} + removeLabel={strings.removeFromFavorites} + hide={!isHovered} + /> + )} {badge ?? diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index 9028a7fb..d1c44576 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -112,11 +112,11 @@ export const IModelGrid = (props: IModelGridProps) => { accessToken={props.accessToken} serverEnvironmentPrefix={props.apiOverrides?.serverEnvironmentPrefix} > - + ); }; -const IModelGridInner = ({ +const ITwinGridInternal = ({ accessToken, apiOverrides, iModelActions, diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index b2cea9f5..5b762ab7 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -2,10 +2,10 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { SvgStar, SvgStarHollow } from "@itwin/itwinui-icons-react"; -import { IconButton, Tile } from "@itwin/itwinui-react"; +import { Tile } from "@itwin/itwinui-react"; import React from "react"; +import { TileFavoriteIcon } from "../../components/tileFavoriteIcon/TileFavoriteIcon"; import { useIModelFavoritesContext } from "../../contexts/IModelFavoritesContext"; import { AccessTokenProvider, ApiOverrides, IModelFull } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; @@ -128,6 +128,8 @@ export const IModelTile = ({ ] ); + const [isHovered, setIsHovered] = React.useState(false); + return ( setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} {...rest} > @@ -154,30 +158,14 @@ export const IModelTile = ({ {rightIcon} {favoritesContext && ( - { - favoritesContext.favorites.has(iModel.id) - ? await favoritesContext.remove(iModel.id) - : await favoritesContext.add(iModel.id); - }} - style={{ - paddingInline: "var(--iui-button-padding-block)", - backgroundColor: - "rgb(from var(--iui-color-background) r g b / 0.7)", - }} - styleType="borderless" - > - {favoritesContext.favorites.has(iModel.id) ? ( - - ) : ( - - )} - + favoritesContext.add(iModel.id)} + onRemoveFromFavorites={() => favoritesContext.remove(iModel.id)} + addLabel={strings.addToFavorites} + removeLabel={strings.removeFromFavorites} + hide={!isHovered} + /> )} {thumbnail ? ( From c874b2b4bb077c35bdb2b604bfb4f2584ba4d8e5 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 16:52:20 -0400 Subject: [PATCH 23/34] Add handling for thumbnail click to invoke tilePropsOnClick or onThumbnailClick --- .../imodel-browser/src/containers/iModelTiles/IModelTile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 5b762ab7..442b790c 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -107,6 +107,8 @@ export const IModelTile = ({ const handleClickAndAddToRecents = React.useCallback( async (e: React.MouseEvent) => { + tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel); + if (!accessToken) { return; } @@ -116,8 +118,6 @@ export const IModelTile = ({ accessToken, serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, }); - - tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel); }, [ accessToken, From 057de9f9f7b3a24b933f34e5acce11c79e0e1a7d Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Fri, 24 Oct 2025 17:32:09 -0400 Subject: [PATCH 24/34] Implement client-side filtering for recents and favorites based on searchText --- .../containers/iModelGrid/useIModelData.ts | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index 47a78f56..99ade45c 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts @@ -61,6 +61,24 @@ export const useIModelData = ({ sortOptions, requestType !== "recents" ); + + // For recents and favorites, apply client-side filtering based on searchText + const filteredIModels = React.useMemo(() => { + if ( + !searchText?.trim() || + (requestType !== "recents" && requestType !== "favorites") + ) { + return sortedIModels; + } + + const lowerSearchText = searchText.toLowerCase(); + return sortedIModels.filter( + (iModel) => + (iModel.name?.toLowerCase().includes(lowerSearchText) ?? false) || + (iModel.description?.toLowerCase().includes(lowerSearchText) ?? false) + ); + }, [sortedIModels, searchText, requestType]); + const sortChanged = sortOptions?.descending !== previousSortOptions?.descending || sortOptions?.sortType !== previousSortOptions?.sortType; @@ -95,7 +113,9 @@ export const useIModelData = ({ React.useEffect(() => { // start from scratch when any external state changes - reset(); + if (requestType !== "recents" && requestType !== "favorites") { + reset(); + } }, [ iTwinId, accessToken, @@ -110,6 +130,24 @@ export const useIModelData = ({ reset, ]); + React.useEffect(() => { + // start from scratch when any external state changes + if (requestType === "recents" || requestType === "favorites") { + reset(); + } + }, [ + iTwinId, + accessToken, + sortOptions?.descending, + sortOptions?.sortType, + apiOverrides?.data, + apiOverrides?.serverEnvironmentPrefix, + pageSize, + maxCount, + requestType, + reset, + ]); + // Main function React.useEffect(() => { if (!needsUpdate) { @@ -198,7 +236,7 @@ export const useIModelData = ({ ]); return { - iModels: sortedIModels, + iModels: filteredIModels, status, fetchMore: morePagesAvailable ? fetchMore : undefined, refetchIModels: reset, @@ -247,9 +285,11 @@ const createFetchIModelsFn = ( : ""; const top = maxCount ? Math.min(pageSize, maxCount - skip) : pageSize; const paging = `&$skip=${skip}&$top=${top}`; - const searching = searchText?.trim() - ? `&$search=${encodeURIComponent(searchText)}` - : ""; + // Only apply server-side search for non-recents and non-favorites requests + const searching = + searchText?.trim() && !["favorites", "recents"].includes(requestType) + ? `&$search=${encodeURIComponent(searchText)}` + : ""; const abortController = new AbortController(); const url = `${_getAPIServer( From ef38ce1b0265ccb6effbd73cb86b82b47a8ba9bf Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 27 Oct 2025 12:43:06 -0400 Subject: [PATCH 25/34] Refactor iModel favorites management: streamline API calls and enhance documentation --- .../containers/ITwinGrid/useITwinFavorites.ts | 6 +- .../src/containers/iModelGrid/IModelGrid.tsx | 2 +- .../containers/iModelGrid/useIModelData.ts | 1 + .../iModelGrid/useIModelFavorites.ts | 118 ++++-------------- .../src/containers/iModelTiles/IModelTile.tsx | 2 +- .../imodel-browser/src/utils/imodelApi.ts | 98 +++++++++++++++ 6 files changed, 130 insertions(+), 97 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts index 017d596b..da90a812 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts @@ -13,9 +13,9 @@ const HOOK_ABORT_ERROR = /** * Custom hook to manage iTwin favorites. - * @param {AccessTokenProvider} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. - * @param {ApiOverrides} [apiOverrides] - Optional API overrides. - * @returns {object} - An object containing: + * @param accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param serverEnvironmentPrefix Optional server environment prefix to target different environments. Can be "dev", "qa", or "" (empty string for production) + * @returns {object} An object containing: * - {Set} iTwinFavorites - A set of iTwin IDs that are marked as favorites. * - {function} addITwinToFavorites - A function to add an iTwin to favorites. * - {function} removeITwinFromFavorites - A function to remove an iTwin from favorites. diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index d1c44576..e1762f95 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -20,7 +20,7 @@ import { } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; -import { addIModelToRecents } from "../../utils/imodelApi"; +import { addIModelToRecents } from "../../utils/iModelApi"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; import styles from "./IModelGrid.module.scss"; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index 99ade45c..8b83614d 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts @@ -16,6 +16,7 @@ import { _getAPIServer } from "../../utils/_apiOverrides"; import { useIModelSort } from "./useIModelSort"; export interface IModelDataHookOptions { + /** Type of iModels to request - "favorites" for user's favorite iModels, "recents" for recently accessed iModels, or empty string for all iModels */ requestType?: "favorites" | "recents" | ""; iTwinId?: string | undefined; accessToken?: AccessTokenProvider; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts index b9a1ee85..bde97048 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts @@ -6,20 +6,14 @@ import { useCallback, useEffect, useState } from "react"; import { AccessTokenProvider } from "../../types"; -import { _getAPIServer } from "../../utils/_apiOverrides"; -import { - IModelFavorites, - IModelFavoritesResponse, -} from "../../utils/imodelApi"; - -const HOOK_ABORT_ERROR = - "The fetch request was aborted by the cleanup function."; +import * as iModelApi from "../../utils/iModelApi"; /** * Custom hook to manage iModel favorites. - * @param {AccessTokenProvider} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. - * @param {ApiOverrides} [apiOverrides] - Optional API overrides. - * @returns {object} - An object containing: + * @param iTwinId - The ID of the iTwin for which to fetch favorites. + * @param accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. + * @param serverEnvironmentPrefix - Optional server environment prefix. + * @returns An object containing: * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. * - {function} addIModelToFavorites - A function to add an iModel to favorites. * - {function} removeIModelFromFavorites - A function to remove an iModel from favorites. @@ -45,25 +39,13 @@ export const useIModelFavorites = ( if (!accessToken || !iModelId || iModelId === "") { return; } - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/imodels/favorites/${iModelId}`; try { - const result = await fetch(url, { - method: "PUT", - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, + await iModelApi.addIModelToFavorites({ + iModelId, + accessToken, + serverEnvironmentPrefix, }); - if (!result || (result.status !== 200 && result.status !== 204)) { - throw new Error(`Failed to add iModel ${iModelId} to favorites`); - } - setIModelFavorites((prev) => new Set([...prev, iModelId])); } catch (error) { console.error(error); @@ -82,25 +64,13 @@ export const useIModelFavorites = ( if (!accessToken || !iModelId || iModelId === "") { return; } - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/imodels/favorites/${iModelId}`; try { - const result = await fetch(url, { - method: "DELETE", - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, + await iModelApi.removeIModelFromFavorites({ + iModelId, + accessToken, + serverEnvironmentPrefix, }); - if (!result || (result.status !== 200 && result.status !== 204)) { - throw new Error(`Failed to remove iModel ${iModelId} from favorites`); - } - setIModelFavorites((prev) => { const newFavorites = new Set(prev); newFavorites.delete(iModelId); @@ -113,49 +83,6 @@ export const useIModelFavorites = ( [accessToken, serverEnvironmentPrefix] ); - /** - * Fetches iTwin favorites from the API. - * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. - * @returns {Promise} - A promise that resolves to an array of iTwin favorites. - * @throws {Error} - Throws an error if the fetch request fails. - */ - const getIModelFavorites = useCallback( - async (abortSignal?: AbortSignal): Promise => { - if (!accessToken || !iTwinId) { - return []; - } - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/imodels/favorites?iTwinId=${iTwinId}`; - const result = await fetch(url, { - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, - signal: abortSignal, - }); - if (abortSignal?.aborted) { - throw new Error(HOOK_ABORT_ERROR); - } - if (!result) { - throw new Error( - `Failed to fetch iModels favorites from ${url}.\nNo response.` - ); - } - if (result.status !== 200) { - throw new Error( - `Failed to fetch iModels favorites from ${url}.\nStatus: ${result.status}` - ); - } - const response: IModelFavoritesResponse = await result.json(); - return response.iModels; - }, - [accessToken, iTwinId, serverEnvironmentPrefix] - ); - useEffect(() => { const controller = new AbortController(); /** @@ -164,13 +91,20 @@ export const useIModelFavorites = ( */ const fetchIModelFavorites = async (abortSignal?: AbortSignal) => { try { - const favorites = await getIModelFavorites(abortSignal); + if (!iTwinId || !accessToken) { + setIModelFavorites(new Set()); + return; + } + + const favorites = await iModelApi.getIModelFavorites({ + iTwinId, + accessToken, + serverEnvironmentPrefix, + abortSignal, + }); setIModelFavorites(new Set(favorites.map((favorite) => favorite.id))); } catch (error) { - if ( - error === HOOK_ABORT_ERROR || - (error instanceof Error && error.name === "AbortError") - ) { + if (error instanceof Error && error.name === "AbortError") { return; } console.error(error); @@ -181,7 +115,7 @@ export const useIModelFavorites = ( return () => { controller.abort(); }; - }, [getIModelFavorites]); + }, [iTwinId, accessToken, serverEnvironmentPrefix]); return { iModelFavorites, diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 442b790c..5cc7255f 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -13,7 +13,7 @@ import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; -import { addIModelToRecents } from "../../utils/imodelApi"; +import { addIModelToRecents } from "../../utils/iModelApi"; import { IModelThumbnail } from "../iModelThumbnail/IModelThumbnail"; type TileProps = React.ComponentPropsWithoutRef; diff --git a/packages/modules/imodel-browser/src/utils/imodelApi.ts b/packages/modules/imodel-browser/src/utils/imodelApi.ts index 20af5fa6..43c9d7b5 100644 --- a/packages/modules/imodel-browser/src/utils/imodelApi.ts +++ b/packages/modules/imodel-browser/src/utils/imodelApi.ts @@ -95,3 +95,101 @@ export async function removeIModelFromRecents(options: { console.error("Failed to remove iModel from recents", e); } } + +export async function getIModelFavorites(options: { + iTwinId: string; + accessToken: AccessTokenProvider; + serverEnvironmentPrefix?: "dev" | "qa" | ""; + abortSignal?: AbortSignal; +}): Promise { + const { iTwinId, accessToken, serverEnvironmentPrefix, abortSignal } = + options; + + const token = + typeof accessToken === "function" ? await accessToken() : accessToken; + + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites?iTwinId=${iTwinId}`; + + const result = await fetch(url, { + headers: { + authorization: token as string, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + signal: abortSignal, + }); + + if (abortSignal?.aborted) { + throw new Error("The fetch request was aborted by the cleanup function."); + } + + if (!result) { + throw new Error( + `Failed to fetch iModels favorites from ${url}.\nNo response.` + ); + } + + if (result.status !== 200) { + throw new Error( + `Failed to fetch iModels favorites from ${url}.\nStatus: ${result.status}` + ); + } + + const response: IModelFavoritesResponse = await result.json(); + return response.iModels; +} + +export async function addIModelToFavorites(options: { + iModelId: string; + accessToken: AccessTokenProvider; + serverEnvironmentPrefix?: "dev" | "qa" | ""; +}): Promise { + const { iModelId, accessToken, serverEnvironmentPrefix } = options; + + const token = + typeof accessToken === "function" ? await accessToken() : accessToken; + + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites/${iModelId}`; + + const result = await fetch(url, { + method: "PUT", + headers: { + authorization: token as string, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + + if (!result || (result.status !== 200 && result.status !== 204)) { + throw new Error(`Failed to add iModel ${iModelId} to favorites`); + } +} + +export async function removeIModelFromFavorites(options: { + iModelId: string; + accessToken: AccessTokenProvider; + serverEnvironmentPrefix?: "dev" | "qa" | ""; +}): Promise { + const { iModelId, accessToken, serverEnvironmentPrefix } = options; + + const token = + typeof accessToken === "function" ? await accessToken() : accessToken; + + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/imodels/favorites/${iModelId}`; + + const result = await fetch(url, { + method: "DELETE", + headers: { + authorization: token as string, + Accept: "application/vnd.bentley.itwin-platform.v2+json", + }, + }); + + if (!result || (result.status !== 200 && result.status !== 204)) { + throw new Error(`Failed to remove iModel ${iModelId} from favorites`); + } +} From 751593d51e58a8128871ae02cd00762ff01c517e Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 27 Oct 2025 13:20:18 -0400 Subject: [PATCH 26/34] Replace SvgStar with SvgApple in IModelGrid story and adjust cellOverrides for conditional rendering --- .../src/imodel-browser/IModelGrid.stories.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx index 6cb0d281..506d1ba8 100644 --- a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx @@ -9,7 +9,7 @@ import { IModelGridProps, IModelTileProps, } from "@itwin/imodel-browser-react"; -import { SvgStar } from "@itwin/itwinui-icons-react"; +import { SvgApple } from "@itwin/itwinui-icons-react"; import { Button, Code, @@ -50,6 +50,7 @@ const Template: Story = withITwinIdOverride( export const Primary = Template.bind({}); Primary.args = { apiOverrides: { serverEnvironmentPrefix: "qa" }, + sortOptions: { sortType: "name", descending: false }, }; export const PrimaryCell = Template.bind({}); @@ -63,14 +64,17 @@ OverrideCellData.args = { apiOverrides: { serverEnvironmentPrefix: "qa" }, viewMode: "cells", cellOverrides: { - name: (props) => ( -
- - - - {props.value} -
- ), + name: (props) => + props.value.includes("a") ? ( +
+ + + + {props.value} +
+ ) : ( + props.value + ), description: (props) => {props.value}, }, }; From 735e8c6ad2abd4222160d0f7318a84422304fbb4 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 27 Oct 2025 14:27:25 -0400 Subject: [PATCH 27/34] Add disableAddToRecents prop to IModelGrid and update IModelTile click handling --- .../src/imodel-browser/IModelGrid.stories.tsx | 25 ++++++++ .../apps/storybook/src/utils/storyHelp.ts | 2 +- .../src/containers/iModelGrid/IModelGrid.tsx | 64 +++++++++++++------ .../src/containers/iModelTiles/IModelTile.tsx | 27 +------- 4 files changed, 73 insertions(+), 45 deletions(-) diff --git a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx index 506d1ba8..74fd8029 100644 --- a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx @@ -315,6 +315,7 @@ export const WithPostProcessCallback: Story = WithPostProcessCallback.args = { apiOverrides: { serverEnvironmentPrefix: "qa" }, }; + export const DefaultNoStateComponentOverride = Template.bind({}); DefaultNoStateComponentOverride.args = { apiOverrides: { serverEnvironmentPrefix: "qa" }, @@ -324,3 +325,27 @@ DefaultNoStateComponentOverride.args = { ), }; + +export const DisableAddToRecents = Template.bind({}); +DisableAddToRecents.args = { + apiOverrides: { serverEnvironmentPrefix: "qa" }, + disableAddToRecents: true, +}; +DisableAddToRecents.argTypes = { + accessToken: { table: { disable: true } }, + onThumbnailClick: { table: { disable: true } }, + sortOptions: { table: { disable: true } }, + iModelActions: { table: { disable: true } }, + useIndividualState: { table: { disable: true } }, + tileOverrides: { table: { disable: true } }, + stringsOverrides: { table: { disable: true } }, + apiOverrides: { table: { disable: true } }, + postProcessCallback: { table: { disable: true } }, + emptyStateComponent: { table: { disable: true } }, + searchText: { table: { disable: true } }, + viewMode: { table: { disable: true } }, + pageSize: { table: { disable: true } }, + maxCount: { table: { disable: true } }, + cellOverrides: { table: { disable: true } }, + className: { table: { disable: true } }, +}; diff --git a/packages/apps/storybook/src/utils/storyHelp.ts b/packages/apps/storybook/src/utils/storyHelp.ts index 51ed1b1e..47847604 100644 --- a/packages/apps/storybook/src/utils/storyHelp.ts +++ b/packages/apps/storybook/src/utils/storyHelp.ts @@ -24,4 +24,4 @@ export const withAccessTokenOverride: < export const withITwinIdOverride: ( story: Story ) => Story = (Story) => (args, context) => - Story({ ...args, iTwinId: args.iTwinId || context.globals.iTwinId }, context); + Story({ ...args, iTwinId: args.iTwinId ?? context.globals.iTwinId }, context); diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index e1762f95..35d167e6 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -37,6 +37,8 @@ export interface IModelGridProps { requestType?: "favorites" | "recents" | ""; /** Thumbnail click handler. */ onThumbnailClick?(iModel: IModelFull): void; + /** When true, prevents automatically adding iModels to recents when thumbnail is clicked. Default is false. */ + disableAddToRecents?: boolean; /** Configure IModel sorting behavior. */ sortOptions?: IModelSortOptions; @@ -135,6 +137,7 @@ const ITwinGridInternal = ({ maxCount, cellOverrides, className, + disableAddToRecents = false, }: IModelGridProps) => { const [sort, setSort] = React.useState(sortOptions); const [isSortOnTable, setIsSortOnTable] = React.useState(false); @@ -214,26 +217,32 @@ const ITwinGridInternal = ({ } }, [iModels.length, pageSize, fetchMore, fetchStatus]); + const iModelClickAndAddToRecents = async ( + iModel: IModelFull, + clickFn: () => void + ) => { + try { + if (!accessToken || disableAddToRecents) { + clickFn(); + return; + } + + void addIModelToRecents({ + iModelId: iModel.id, + accessToken, + serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, + }); + } catch (e) { + // swallow errors to avoid disrupting the UI + console.error("Failed to add iModel to recents", e); + } + onThumbnailClick?.(iModel); + }; + const { columns, onRowClick } = useIModelTableConfig({ iModelActions, - onThumbnailClick: async (iModel) => { - try { - if (!accessToken) { - onThumbnailClick?.(iModel); - return; - } - - void addIModelToRecents({ - iModelId: iModel.id, - accessToken, - serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, - }); - } catch (e) { - // swallow errors to avoid disrupting the UI - console.error("Failed to add iModel to recents", e); - } - onThumbnailClick?.(iModel); - }, + onThumbnailClick: (iModel) => + iModelClickAndAddToRecents(iModel, () => onThumbnailClick?.(iModel)), strings, refetchIModels, cellOverrides, @@ -262,12 +271,29 @@ const ITwinGridInternal = ({ iModel={iModel} iModelOptions={iModelActions} accessToken={accessToken} - onThumbnailClick={onThumbnailClick} + onThumbnailClick={(iModel) => + iModelClickAndAddToRecents(iModel, () => + onThumbnailClick?.(iModel) + ) + } apiOverrides={tileApiOverrides} useTileState={useIndividualState} refetchIModels={refetchIModels} {...cellOverrides} {...tileOverrides} + tileProps={ + tileOverrides + ? { + ...tileOverrides.tileProps, + onClick: tileOverrides.tileProps?.onClick + ? (e) => + iModelClickAndAddToRecents(iModel, () => + tileOverrides.tileProps?.onClick?.(e) + ) + : undefined, + } + : undefined + } /> ))} {fetchMore ? ( diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 5cc7255f..2c13937a 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -105,29 +105,6 @@ export const IModelTile = ({ } : undefined; - const handleClickAndAddToRecents = React.useCallback( - async (e: React.MouseEvent) => { - tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel); - - if (!accessToken) { - return; - } - - void addIModelToRecents({ - iModelId: iModel.id, - accessToken, - serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, - }); - }, - [ - accessToken, - apiOverrides?.serverEnvironmentPrefix, - iModel, - tilePropsOnClick, - onThumbnailClick, - ] - ); - const [isHovered, setIsHovered] = React.useState(false); return ( @@ -146,7 +123,7 @@ export const IModelTile = ({ tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel)} aria-disabled={isDisabled} data-testid={`iModel-tile-${iModel.id}-name-label`} > @@ -185,7 +162,7 @@ export const IModelTile = ({ tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel)} aria-disabled={isDisabled} data-testid={`iModel-tile-${iModel.id}-action`} > From 28ce0e5793e0527105f2724d504f34bb6497e8c9 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 27 Oct 2025 14:30:30 -0400 Subject: [PATCH 28/34] Remove unused import of addIModelToRecents from IModelTile --- .../imodel-browser/src/containers/iModelTiles/IModelTile.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index 2c13937a..c7f71734 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -13,7 +13,6 @@ import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; -import { addIModelToRecents } from "../../utils/iModelApi"; import { IModelThumbnail } from "../iModelThumbnail/IModelThumbnail"; type TileProps = React.ComponentPropsWithoutRef; From 4d688027c62c891ac3d38d1a7b4f8cedb2722561 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 27 Oct 2025 15:42:16 -0400 Subject: [PATCH 29/34] Rename iModelApi to iModelApi-rename-fix and update imports in IModelGrid and useIModelFavorites --- .../imodel-browser/src/containers/iModelGrid/IModelGrid.tsx | 2 +- .../src/containers/iModelGrid/useIModelFavorites.ts | 2 +- .../src/utils/{imodelApi.ts => iModelApi-rename-fix.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/modules/imodel-browser/src/utils/{imodelApi.ts => iModelApi-rename-fix.ts} (100%) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index 35d167e6..c0efd7da 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -20,7 +20,7 @@ import { } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; -import { addIModelToRecents } from "../../utils/iModelApi"; +import { addIModelToRecents } from "../../utils/iModelApi-rename-fix"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; import styles from "./IModelGrid.module.scss"; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts index bde97048..d1f20d84 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts @@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from "react"; import { AccessTokenProvider } from "../../types"; -import * as iModelApi from "../../utils/iModelApi"; +import * as iModelApi from "../../utils/iModelApi-rename-fix"; /** * Custom hook to manage iModel favorites. diff --git a/packages/modules/imodel-browser/src/utils/imodelApi.ts b/packages/modules/imodel-browser/src/utils/iModelApi-rename-fix.ts similarity index 100% rename from packages/modules/imodel-browser/src/utils/imodelApi.ts rename to packages/modules/imodel-browser/src/utils/iModelApi-rename-fix.ts From 5dc9eecd10e5bc90cc96ec91441ec9260f43b7ec Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 27 Oct 2025 15:43:38 -0400 Subject: [PATCH 30/34] Refactor iModelApi: rename file and update imports in IModelGrid and useIModelFavorites --- .../imodel-browser/src/containers/iModelGrid/IModelGrid.tsx | 2 +- .../src/containers/iModelGrid/useIModelFavorites.ts | 2 +- .../src/utils/{iModelApi-rename-fix.ts => iModelApi.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/modules/imodel-browser/src/utils/{iModelApi-rename-fix.ts => iModelApi.ts} (100%) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index c0efd7da..35d167e6 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -20,7 +20,7 @@ import { } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; -import { addIModelToRecents } from "../../utils/iModelApi-rename-fix"; +import { addIModelToRecents } from "../../utils/iModelApi"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; import styles from "./IModelGrid.module.scss"; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts index d1f20d84..bde97048 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts @@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from "react"; import { AccessTokenProvider } from "../../types"; -import * as iModelApi from "../../utils/iModelApi-rename-fix"; +import * as iModelApi from "../../utils/iModelApi"; /** * Custom hook to manage iModel favorites. diff --git a/packages/modules/imodel-browser/src/utils/iModelApi-rename-fix.ts b/packages/modules/imodel-browser/src/utils/iModelApi.ts similarity index 100% rename from packages/modules/imodel-browser/src/utils/iModelApi-rename-fix.ts rename to packages/modules/imodel-browser/src/utils/iModelApi.ts From 37544557b66c89fa25883acef23e69a71dd2c22a Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Thu, 30 Oct 2025 11:46:11 -0400 Subject: [PATCH 31/34] Add hideFavoriteIcon prop to ITwinTile and IModelTile components --- .../imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx | 6 +++++- .../src/containers/iModelTiles/IModelTile.tsx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx index 0035529a..2c21261f 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx @@ -46,6 +46,8 @@ export interface ITwinTileProps { refetchITwins?: () => void; /** Indicates whether the tile should take the full width of its container */ fullWidth?: boolean; + /** Hides the favorite icon when true */ + hideFavoriteIcon?: boolean; } /** @@ -62,6 +64,7 @@ export const ITwinTile = ({ removeFromFavorites, refetchITwins, fullWidth, + hideFavoriteIcon, }: ITwinTileProps) => { const { name, @@ -133,7 +136,8 @@ export const ITwinTile = ({ {leftIcon && {leftIcon}} {rightIcon} - {isFavorite !== undefined && + {!hideFavoriteIcon && + isFavorite !== undefined && addToFavorites && removeFromFavorites && ( void; /** Indicates whether the tile should take the full width of its container */ fullWidth?: boolean; + /** Hides the favorite icon when true */ + hideFavoriteIcon?: boolean; } /** @@ -58,6 +60,7 @@ export const IModelTile = ({ stringsOverrides, refetchIModels, fullWidth, + hideFavoriteIcon, }: IModelTileProps) => { const { name, @@ -133,7 +136,7 @@ export const IModelTile = ({ {leftIcon && {leftIcon}} {rightIcon} - {favoritesContext && ( + {!hideFavoriteIcon && favoritesContext && ( favoritesContext.add(iModel.id)} From 5e83f1ede72f0f204c8365b9c2291f31a1fc6816 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Nov 2025 10:41:01 -0500 Subject: [PATCH 32/34] Add "remove iModel from recents" functionality when in recents mode --- .../src/containers/iModelGrid/IModelGrid.tsx | 47 +++++++++++++++++-- .../imodel-browser/src/utils/iModelApi.ts | 2 +- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index 35d167e6..ed495d0b 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -20,7 +20,10 @@ import { } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; -import { addIModelToRecents } from "../../utils/iModelApi"; +import { + addIModelToRecents, + removeIModelFromRecents, +} from "../../utils/iModelApi"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; import styles from "./IModelGrid.module.scss"; @@ -39,8 +42,7 @@ export interface IModelGridProps { onThumbnailClick?(iModel: IModelFull): void; /** When true, prevents automatically adding iModels to recents when thumbnail is clicked. Default is false. */ disableAddToRecents?: boolean; - /** Configure IModel sorting behavior. - */ + /** Configure IModel sorting behavior. */ sortOptions?: IModelSortOptions; /** List of actions to build for each imodel context menu. */ iModelActions?: ContextMenuBuilderItem[]; @@ -73,6 +75,8 @@ export interface IModelGridProps { noAuthentication?: string; /** Generic message displayed if an error occurs while fetching. */ error?: string; + /** Displayed in context menu for removing iModel from recents. */ + removeFromRecents?: string; }; /** Object that configures different overrides for the API. * @property `data`: Array of iModels used in the grid. @@ -180,9 +184,42 @@ const ITwinGridInternal = ({ error: "An error occurred", addToFavorites: "Add to favorites", removeFromFavorites: "Remove from favorites", + removeFromRecents: "Remove from recents", }, stringsOverrides ); + + // Add "Remove from recents" action when viewing recents + const enhancedIModelActions = React.useMemo(() => { + if (requestType === "recents") { + const removeFromRecentsAction: ContextMenuBuilderItem = { + key: "remove-from-recents", + children: strings.removeFromRecents, + onClick: async (iModel, refetchData) => { + if (!iModel || !accessToken) { + return; + } + await removeIModelFromRecents({ + iModelId: iModel.id, + accessToken, + serverEnvironmentPrefix: apiOverrides?.serverEnvironmentPrefix, + }); + refetchData?.(); + }, + }; + return iModelActions + ? [...iModelActions, removeFromRecentsAction] + : [removeFromRecentsAction]; + } + return iModelActions; + }, [ + requestType, + iModelActions, + strings.removeFromRecents, + accessToken, + apiOverrides?.serverEnvironmentPrefix, + ]); + const { iModels: fetchediModels, status: fetchStatus, @@ -240,7 +277,7 @@ const ITwinGridInternal = ({ }; const { columns, onRowClick } = useIModelTableConfig({ - iModelActions, + iModelActions: enhancedIModelActions, onThumbnailClick: (iModel) => iModelClickAndAddToRecents(iModel, () => onThumbnailClick?.(iModel)), strings, @@ -269,7 +306,7 @@ const ITwinGridInternal = ({ iModelClickAndAddToRecents(iModel, () => diff --git a/packages/modules/imodel-browser/src/utils/iModelApi.ts b/packages/modules/imodel-browser/src/utils/iModelApi.ts index 43c9d7b5..b00c983c 100644 --- a/packages/modules/imodel-browser/src/utils/iModelApi.ts +++ b/packages/modules/imodel-browser/src/utils/iModelApi.ts @@ -81,7 +81,7 @@ export async function removeIModelFromRecents(options: { serverEnvironmentPrefix )}/imodels/recents/${encodeURIComponent(iModelId)}`; - void fetch(url, { + await fetch(url, { method: "DELETE", headers: { authorization: token as string, From 859429a15174c3adb8e27a855721b83b1640fd19 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Nov 2025 14:17:01 -0500 Subject: [PATCH 33/34] Use css for hover behavior instead of javascript --- .../tileFavoriteIcon/TileFavoriteIcon.tsx | 13 ++++--------- .../containers/ITwinGrid/ITwinTile.module.scss | 17 +++++++++++++++++ .../src/containers/ITwinGrid/ITwinTile.tsx | 13 ++++++++----- .../iModelTiles/IModelTile.module.scss | 17 +++++++++++++++++ .../src/containers/iModelTiles/IModelTile.tsx | 14 ++++++++------ 5 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.module.scss create mode 100644 packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.module.scss diff --git a/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx index 45d58e1d..8a4ab348 100644 --- a/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx +++ b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx @@ -19,8 +19,8 @@ export interface TileFavoriteIconProps { addLabel: string; /** Accessible label for removing from favorites */ removeLabel: string; - /** Whether to hide the icon (will not hide if the item is favorited) */ - hide?: boolean; + /** CSS class to apply to the icon */ + className?: string; } /** @@ -33,20 +33,15 @@ export const TileFavoriteIcon = ({ onRemoveFromFavorites, addLabel, removeLabel, - hide, + className = "", }: TileFavoriteIconProps) => { - // Don't render if hide is true and not favorited - if (hide && !isFavorite) { - return null; - } - return ( { isFavorite ? await onRemoveFromFavorites() : await onAddToFavorites(); }} - className={styles.favoriteIconButton} + className={`${styles.favoriteIconButton} ${className}`} styleType="borderless" > {isFavorite ? : } diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.module.scss b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.module.scss new file mode 100644 index 00000000..cbde2e3a --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.module.scss @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +.fullWidth { + width: 100%; +} + +.iTwinTileFavoriteIcon.hidden { + display: none; +} + +.iTwinTile:hover { + .iTwinTileFavoriteIcon { + display: block; + } +} \ No newline at end of file diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx index 2c21261f..43f70fd8 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx @@ -13,6 +13,7 @@ import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; +import styles from "./ITwinTile.module.scss"; export type TileProps = React.ComponentPropsWithoutRef; @@ -83,9 +84,9 @@ export const ITwinTile = ({ children, isDisabled, onClick, + className = "", ...rest } = tileProps ?? {}; - const [isHovered, setIsHovered] = React.useState(false); const strings = _mergeStrings( { trialBadge: "Trial", @@ -115,9 +116,9 @@ export const ITwinTile = ({ isLoading={isLoading} status={status} isDisabled={isDisabled} - style={fullWidth ? { width: "100%" } : undefined} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + className={`${styles.iTwinTile} ${ + fullWidth ? styles.fullWidth : "" + } ${className}`} {...rest} > @@ -146,7 +147,9 @@ export const ITwinTile = ({ onRemoveFromFavorites={() => removeFromFavorites(iTwin.id)} addLabel={strings.addToFavorites} removeLabel={strings.removeFromFavorites} - hide={!isHovered} + className={`${styles.iTwinTileFavoriteIcon} ${ + !isFavorite && styles.hidden + }`} /> )} diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.module.scss b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.module.scss new file mode 100644 index 00000000..2503bc28 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.module.scss @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +.fullWidth { + width: 100%; +} + +.iModelTileFavoriteIcon.hidden { + display: none; +} + +.iModelTile:hover { + .iModelTileFavoriteIcon { + display: block; + } +} \ No newline at end of file diff --git a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx index c512ab4f..5a0ba12d 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -14,6 +14,7 @@ import { ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; import { IModelThumbnail } from "../iModelThumbnail/IModelThumbnail"; +import styles from "./IModelTile.module.scss"; type TileProps = React.ComponentPropsWithoutRef; @@ -78,6 +79,7 @@ export const IModelTile = ({ isDisabled, onClick: tilePropsOnClick, metadata, + className = "", ...rest } = tileProps ?? {}; const favoritesContext = useIModelFavoritesContext(); @@ -107,8 +109,6 @@ export const IModelTile = ({ } : undefined; - const [isHovered, setIsHovered] = React.useState(false); - return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + className={`${styles.iModelTile} ${ + fullWidth ? styles.fullWidth : "" + } ${className}`} {...rest} > @@ -143,7 +143,9 @@ export const IModelTile = ({ onRemoveFromFavorites={() => favoritesContext.remove(iModel.id)} addLabel={strings.addToFavorites} removeLabel={strings.removeFromFavorites} - hide={!isHovered} + className={`${styles.iModelTileFavoriteIcon} ${ + !favoritesContext.favorites.has(iModel.id) && styles.hidden + }`} /> )} From 72946fd3daf494ccfb530087ed908e7f8a9e66c7 Mon Sep 17 00:00:00 2001 From: "Omar H." Date: Mon, 3 Nov 2025 18:29:07 -0500 Subject: [PATCH 34/34] Refactor: Update onThumbnailClick documentation to clarify recents behavior and remove unused favorites hook --- .../src/containers/iModelGrid/IModelGrid.tsx | 2 +- .../iModelGrid/useIIModelFavorites.ts | 208 ------------------ 2 files changed, 1 insertion(+), 209 deletions(-) delete mode 100644 packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx index ed495d0b..0315b3a6 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -38,7 +38,7 @@ export interface IModelGridProps { iTwinId?: string | undefined; /** Type of iModels to request - "favorites" for user's favorite iModels, "recents" for recently accessed iModels, or empty string for all iModels */ requestType?: "favorites" | "recents" | ""; - /** Thumbnail click handler. */ + /** Thumbnail click handler. Adds iModel to recents when clicked unless disableAddToRecents is true. */ onThumbnailClick?(iModel: IModelFull): void; /** When true, prevents automatically adding iModels to recents when thumbnail is clicked. Default is false. */ disableAddToRecents?: boolean; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts deleted file mode 100644 index f6aa39ce..00000000 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIIModelFavorites.ts +++ /dev/null @@ -1,208 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Bentley Systems, Incorporated. All rights reserved. - * See LICENSE.md in the project root for license terms and full copyright notice. - *--------------------------------------------------------------------------------------------*/ - -import { useCallback, useEffect, useState } from "react"; - -import { AccessTokenProvider } from "../../types"; -import { _getAPIServer } from "../../utils/_apiOverrides"; - -const HOOK_ABORT_ERROR = - "iModel favorites request was aborted by the cleanup function."; - -/** - * Custom hook to manage iModel favorites. - * @param {AccessTokenProvider} accessToken - Access token that requires the `itwin-platform` scope. Provide a function that returns the token to prevent the token from expiring. - * @param {ApiOverrides} [apiOverrides] - Optional API overrides. - * @returns {object} - An object containing: - * - {Set} iModelFavorites - A set of iModel IDs that are marked as favorites. - * - {function} addIModelToFavorites - A function to add an iModel to favorites. - * - {function} removeIModelFromFavorites - A function to remove an iModel from favorites. - */ -export const useIModelFavorites = ( - iTwinId: string | undefined, - accessToken: AccessTokenProvider, - serverEnvironmentPrefix?: "dev" | "qa" | "" -): { - iModelFavorites: Set; - addIModelToFavorites: (iModelId: string) => Promise; - removeIModelFromFavorites: (iModelId: string) => Promise; -} => { - const [iModelFavorites, setIModelFavorites] = useState(new Set()); - - /** - * Adds an iModel to the favorites. - * @param {string} iModelId - The ID of the iModel to add to favorites. - * @returns {Promise} - */ - const addIModelToFavorites = useCallback( - async (iModelId: string): Promise => { - if (!accessToken || !iModelId || iModelId === "") { - return; - } - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/imodels/favorites/${iModelId}`; - try { - const result = await fetch(url, { - method: "PUT", - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, - }); - - if (!result || (result.status !== 200 && result.status !== 204)) { - throw new Error(`Failed to add iModel ${iModelId} to favorites`); - } - - setIModelFavorites((prev) => new Set([...prev, iModelId])); - } catch (error) { - console.error(error); - } - }, - [accessToken, serverEnvironmentPrefix] - ); - - /** - * Removes an iModel from the favorites. - * @param {string} iModelId - The ID of the iModel to remove from favorites. - * @returns {Promise} - */ - const removeIModelFromFavorites = useCallback( - async (iModelId: string): Promise => { - if (!accessToken || !iModelId || iModelId === "") { - return; - } - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/imodels/favorites/${iModelId}`; - try { - const result = await fetch(url, { - method: "DELETE", - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, - }); - - if (!result || (result.status !== 200 && result.status !== 204)) { - throw new Error(`Failed to remove iModel ${iModelId} from favorites`); - } - - setIModelFavorites((prev) => { - const newFavorites = new Set(prev); - newFavorites.delete(iModelId); - return newFavorites; - }); - } catch (error) { - console.error(error); - } - }, - [accessToken, serverEnvironmentPrefix] - ); - - /** - * Fetches iTwin favorites from the API. - * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. - * @returns {Promise} - A promise that resolves to an array of iTwin favorites. - * @throws {Error} - Throws an error if the fetch request fails. - */ - const getIModelFavorites = useCallback( - async (abortSignal?: AbortSignal): Promise => { - if (!accessToken || !iTwinId) { - return []; - } - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/imodels/favorites?iTwinId=${iTwinId}`; - const result = await fetch(url, { - headers: { - authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v2+json", - }, - signal: abortSignal, - }); - if (abortSignal?.aborted) { - throw new Error(HOOK_ABORT_ERROR); - } - if (!result) { - throw new Error( - `Failed to fetch iModels favorites from ${url}.\nNo response.` - ); - } - if (result.status !== 200) { - throw new Error( - `Failed to fetch iModels favorites from ${url}.\nStatus: ${result.status}` - ); - } - const response: IModelFavoritesResponse = await result.json(); - return response.iModels; - }, - [accessToken, iTwinId, serverEnvironmentPrefix] - ); - - useEffect(() => { - const controller = new AbortController(); - /** - * Fetches iTwin favorites and updates the state. - * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. - */ - const fetchIModelFavorites = async (abortSignal?: AbortSignal) => { - try { - const favorites = await getIModelFavorites(abortSignal); - setIModelFavorites(new Set(favorites.map((favorite) => favorite.id))); - } catch (error) { - if ( - error === HOOK_ABORT_ERROR || - (error instanceof Error && error.name === "AbortError") - ) { - return; - } - console.error(error); - } - }; - void fetchIModelFavorites(controller.signal); - - return () => { - controller.abort(); - }; - }, [getIModelFavorites]); - - return { - iModelFavorites, - addIModelToFavorites, - removeIModelFromFavorites, - }; -}; - -/** Response from https://developer.bentley.com/apis/imodels-v2/operations/get-my-favorite-imodels/ */ -interface IModelFavoritesResponse { - iModels: IModelFavorites[]; - _links: { - self: { - href: string; - }; - prev?: { - href: string; - }; - next?: { - href: string; - }; - }; -} -interface IModelFavorites { - id: string; - displayName: string; - dataCenterLocation: string; -}