diff --git a/common/changes/@itwin/imodel-browser-react/itwin-favorites_2024-11-21-19-36.json b/common/changes/@itwin/imodel-browser-react/itwin-favorites_2024-11-21-19-36.json new file mode 100644 index 00000000..004a766a --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/itwin-favorites_2024-11-21-19-36.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Add favorites functionality to ITwinGrid and ITwinTile components", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} \ No newline at end of file diff --git a/packages/apps/storybook/.storybook/preview.js b/packages/apps/storybook/.storybook/preview.js index 1292bd62..707f72de 100644 --- a/packages/apps/storybook/.storybook/preview.js +++ b/packages/apps/storybook/.storybook/preview.js @@ -25,12 +25,7 @@ export const parameters = { }, authClientConfig: { clientId: process.env.STORYBOOK_AUTH_CLIENT_ID, - scope: [ - "imodels:read", - "imodels:modify", - "itwins:modify", - "itwins:read", - ].join(" "), + scope: "itwin-platform", authority: "https://qa-ims.bentley.com", }, }; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index 0158b040..7db2e5f0 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -23,6 +23,7 @@ import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { ITwinTile, ITwinTileProps } from "./ITwinTile"; import { useITwinData } from "./useITwinData"; +import { useITwinFavorites } from "./useITwinFavorites"; import { useITwinTableConfig } from "./useITwinTableConfig"; export type IndividualITwinStateHook = ( @@ -107,6 +108,9 @@ export const ITwinGrid = ({ postProcessCallback, viewMode, }: ITwinGridProps) => { + const { iTwinFavorites, addITwinToFavorites, removeITwinFromFavorites } = + useITwinFavorites(accessToken, apiOverrides); + const strings = _mergeStrings( { tableColumnName: "iTwin Number", @@ -183,6 +187,9 @@ export const ITwinGrid = ({ iTwinOptions={iTwinActions} onThumbnailClick={onThumbnailClick} useTileState={useIndividualState} + isFavorite={iTwinFavorites.has(iTwin.id)} + addToFavorites={addITwinToFavorites} + removeFromFavorites={removeITwinFromFavorites} {...tileOverrides} /> ))} diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx index 76e82f89..d34c33e8 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx @@ -2,7 +2,8 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { Badge, ThemeProvider, Tile } from "@itwin/itwinui-react"; +import { SvgStar, SvgStarHollow } from "@itwin/itwinui-icons-react"; +import { Badge, IconButton, ThemeProvider, Tile } from "@itwin/itwinui-react"; import React from "react"; import ITwinIcon from "../../images/itwin.svg"; @@ -31,6 +32,12 @@ export interface ITwinTileProps { }; /** Tile props that will be applied after normal use. (Will override ITwinTile if used) */ tileProps?: Partial; + /** Indicates whether the iTwin is marked as a favorite */ + isFavorite?: boolean; + /** Function to add the iTwin to favorites */ + addToFavorites?(iTwinId: string): Promise; + /** Function to remove the iTwin from favorites */ + removeFromFavorites?(iTwinId: string): Promise; } /** @@ -42,6 +49,9 @@ export const ITwinTile = ({ onThumbnailClick, tileProps, stringsOverrides, + isFavorite, + addToFavorites, + removeFromFavorites, }: ITwinTileProps) => { const strings = _mergeStrings( { @@ -86,6 +96,18 @@ export const ITwinTile = ({ } + rightIcon={ + { + isFavorite + ? await removeFromFavorites?.(iTwin.id) + : await addToFavorites?.(iTwin.id); + }} + styleType="borderless" + > + {isFavorite ? : } + + } {...(tileProps ?? {})} /> diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts new file mode 100644 index 00000000..8d858ea6 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinFavorites.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ApiOverrides, ITwinFull } from "../../types"; +import { _getAPIServer } from "../../utils/_apiOverrides"; + +const HOOK_ABORT_ERROR = + "The fetch request was aborted by the cleanup function."; + +/** + * 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 {ApiOverrides} [apiOverrides] - Optional API overrides. + * @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. + */ +export const useITwinFavorites = ( + accessToken: string | (() => Promise) | undefined, + apiOverrides?: ApiOverrides +): { + iTwinFavorites: Set; + addITwinToFavorites: (iTwinId: string) => Promise; + removeITwinFromFavorites: (iTwinId: string) => Promise; +} => { + const [iTwinFavorites, setITwinFavorites] = useState(new Set()); + + /** + * Adds an iTwin to the favorites. + * @param {string} iTwinId - The ID of the iTwin to add to favorites. + * @returns {Promise} + */ + const addITwinToFavorites = useCallback( + async (iTwinId: string): Promise => { + if (!accessToken || !iTwinId || iTwinId === "") { + return; + } + const url = `${_getAPIServer(apiOverrides)}/itwins/favorites/${iTwinId}`; + try { + const result = await fetch(url, { + method: "POST", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v1+json", + }, + }); + if (!result || result.status !== 200) { + throw new Error(`Failed to add iTwin ${iTwinId} to favorites`); + } + setITwinFavorites((prev) => new Set([...prev, iTwinId])); + } catch (error) { + console.error(error); + } + }, + [accessToken, apiOverrides] + ); + + /** + * Removes an iTwin from the favorites. + * @param {string} iTwinId - The ID of the iTwin to remove from favorites. + * @returns {Promise} + */ + const removeITwinFromFavorites = useCallback( + async (iTwinId: string): Promise => { + if (!accessToken || !iTwinId || iTwinId === "") { + return; + } + const url = `${_getAPIServer(apiOverrides)}/itwins/favorites/${iTwinId}`; + try { + const result = await fetch(url, { + method: "DELETE", + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v1+json", + }, + }); + + if (!result || result.status !== 200) { + throw new Error(`Failed to remove iTwin ${iTwinId} to favorites`); + } + + setITwinFavorites((prev) => { + const newFavorites = new Set(prev); + newFavorites.delete(iTwinId); + return newFavorites; + }); + } catch (error) { + console.error(error); + } + }, + [accessToken, apiOverrides] + ); + + /** + * 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 getITwinFavorites = useCallback( + async (abortSignal?: AbortSignal): Promise => { + if (!accessToken) { + return []; + } + const url = `${_getAPIServer( + apiOverrides + )}/itwins/favorites?subClass=Project`; + const result = await fetch(url, { + headers: { + authorization: + typeof accessToken === "function" + ? await accessToken() + : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v1+json", + }, + signal: abortSignal, + }); + if (abortSignal?.aborted) { + throw new Error(HOOK_ABORT_ERROR); + } + if (!result) { + throw new Error( + `Failed to fetch iTwin favorites from ${url}.\nNo response.` + ); + } + if (result.status !== 200) { + throw new Error( + `Failed to fetch iTwin favorites from ${url}.\nStatus: ${result.status}` + ); + } + const response: ITwinFavoritesResponse = await result.json(); + return response.iTwins; + }, + [accessToken, apiOverrides] + ); + + useEffect(() => { + const controller = new AbortController(); + /** + * Fetches iTwin favorites and updates the state. + * @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the fetch request. + */ + const fetchITwinFavorites = async (abortSignal?: AbortSignal) => { + try { + const favorites = await getITwinFavorites(abortSignal); + setITwinFavorites(new Set(favorites.map((favorite) => favorite.id))); + } catch (error) { + if (error === HOOK_ABORT_ERROR) { + return; + } + console.error(error); + } + }; + void fetchITwinFavorites(controller.signal); + + return () => { + controller.abort(); + }; + }, [getITwinFavorites]); + + return { iTwinFavorites, addITwinToFavorites, removeITwinFromFavorites }; +}; + +/** Response from https://developer.bentley.com/apis/iTwins/operations/get-my-favorite-itwins/ */ +interface ITwinFavoritesResponse { + iTwins: ITwinFavorites[]; + _links: { + self: { + href: string; + }; + prev: { + href: string; + }; + next: { + href: string; + }; + }; +} +interface ITwinFavorites { + id: string; + class: string; + subClass: string; + type: string; + // eslint-disable-next-line id-blacklist + number: string; + displayName: string; +}