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 diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 554b5b7f..7b60c831 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -2871,10 +2871,10 @@ 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 @@ -5334,7 +5334,6 @@ packages: 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==} diff --git a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx index 6cb0d281..74fd8029 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}, }, }; @@ -311,6 +315,7 @@ export const WithPostProcessCallback: Story = WithPostProcessCallback.args = { apiOverrides: { serverEnvironmentPrefix: "qa" }, }; + export const DefaultNoStateComponentOverride = Template.bind({}); DefaultNoStateComponentOverride.args = { apiOverrides: { serverEnvironmentPrefix: "qa" }, @@ -320,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/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/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/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..8a4ab348 --- /dev/null +++ b/packages/modules/imodel-browser/src/components/tileFavoriteIcon/TileFavoriteIcon.tsx @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + /** CSS class to apply to the icon */ + className?: string; +} + +/** + * 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, + className = "", +}: TileFavoriteIconProps) => { + return ( + { + isFavorite ? await onRemoveFromFavorites() : await onAddToFavorites(); + }} + className={`${styles.favoriteIconButton} ${className}`} + styleType="borderless" + > + {isFavorite ? : } + + ); +}; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index 7128c0b3..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 */ @@ -149,7 +150,12 @@ export const ITwinGrid = ({ tableLoadingData: "Loading...", trialBadge: "Trial", inactiveBadge: "Inactive", - noITwins: "No iTwin found.", + noITwins: + requestType === "recents" + ? "No recent iTwins." + : requestType === "favorites" + ? "No favorite iTwins." + : "No iTwin found.", noAuthentication: "No access token provided", error: "An error occurred", addToFavorites: "Add to favorites", 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 e9d4d8c8..43f70fd8 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinTile.tsx @@ -2,16 +2,18 @@ * 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 { _buildManagedContextMenuOptions, ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; +import styles from "./ITwinTile.module.scss"; export type TileProps = React.ComponentPropsWithoutRef; @@ -45,6 +47,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; } /** @@ -61,6 +65,7 @@ export const ITwinTile = ({ removeFromFavorites, refetchITwins, fullWidth, + hideFavoriteIcon, }: ITwinTileProps) => { const { name, @@ -79,9 +84,9 @@ export const ITwinTile = ({ children, isDisabled, onClick, + className = "", ...rest } = tileProps ?? {}; - const strings = _mergeStrings( { trialBadge: "Trial", @@ -111,7 +116,9 @@ export const ITwinTile = ({ isLoading={isLoading} status={status} isDisabled={isDisabled} - style={fullWidth ? { width: "100%" } : undefined} + className={`${styles.iTwinTile} ${ + fullWidth ? styles.fullWidth : "" + } ${className}`} {...rest} > @@ -130,21 +137,21 @@ export const ITwinTile = ({ {leftIcon && {leftIcon}} {rightIcon} - { - isFavorite - ? await removeFromFavorites?.(iTwin.id) - : await addToFavorites?.(iTwin.id); - }} - styleType="borderless" - > - {isFavorite ? : } - + {!hideFavoriteIcon && + isFavorite !== undefined && + addToFavorites && + removeFromFavorites && ( + addToFavorites(iTwin.id)} + onRemoveFromFavorites={() => removeFromFavorites(iTwin.id)} + addLabel={strings.addToFavorites} + removeLabel={strings.removeFromFavorites} + className={`${styles.iTwinTileFavoriteIcon} ${ + !isFavorite && styles.hidden + }`} + /> + )} {badge ?? 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..da90a812 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,9 +13,9 @@ 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 {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. @@ -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 7626788b..0315b3a6 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx @@ -8,7 +8,9 @@ import { InView } from "react-intersection-observer"; import { GridStructure } from "../../components/gridStructure/GridStructure"; import { NoResults } from "../../components/noResults/NoResults"; +import { IModelFavoritesProvider } from "../../contexts/IModelFavoritesContext"; import { + AccessTokenProvider, ApiOverrides, DataStatus, IModelCellOverrides, @@ -18,6 +20,10 @@ import { } from "../../types"; import { _mergeStrings } from "../../utils/_apiOverrides"; import { ContextMenuBuilderItem } from "../../utils/_buildMenuOptions"; +import { + addIModelToRecents, + removeIModelFromRecents, +} from "../../utils/iModelApi"; import { IModelGhostTile } from "../iModelTiles/IModelGhostTile"; import { IModelTile, IModelTileProps } from "../iModelTiles/IModelTile"; import styles from "./IModelGrid.module.scss"; @@ -27,13 +33,16 @@ 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; - /** Thumbnail click handler. */ + /** 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. Adds iModel to recents when clicked unless disableAddToRecents is true. */ onThumbnailClick?(iModel: IModelFull): void; - /** Configure IModel sorting behavior. - */ + /** When true, prevents automatically adding iModels to recents when thumbnail is clicked. Default is false. */ + disableAddToRecents?: boolean; + /** Configure IModel sorting behavior. */ sortOptions?: IModelSortOptions; /** List of actions to build for each imodel context menu. */ iModelActions?: ContextMenuBuilderItem[]; @@ -66,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. @@ -100,13 +111,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 ITwinGridInternal = ({ accessToken, apiOverrides, iModelActions, onThumbnailClick, iTwinId, sortOptions = { sortType: "name", descending: false }, + requestType, stringsOverrides, tileOverrides, useIndividualState, @@ -118,6 +141,7 @@ export const IModelGrid = ({ maxCount, cellOverrides, className, + disableAddToRecents = false, }: IModelGridProps) => { const [sort, setSort] = React.useState(sortOptions); const [isSortOnTable, setIsSortOnTable] = React.useState(false); @@ -141,6 +165,7 @@ export const IModelGrid = ({ const strings = _mergeStrings( { + tableColumnFavorites: "", tableColumnName: "Name", tableColumnDescription: "Description", tableColumnLastModified: "Last Modified", @@ -148,19 +173,60 @@ export const IModelGrid = ({ 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", + 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, fetchMore, refetchIModels, } = useIModelData({ + requestType, accessToken, apiOverrides, iTwinId, @@ -188,9 +254,32 @@ export const IModelGrid = ({ } }, [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, + iModelActions: enhancedIModelActions, + onThumbnailClick: (iModel) => + iModelClickAndAddToRecents(iModel, () => onThumbnailClick?.(iModel)), strings, refetchIModels, cellOverrides, @@ -217,13 +306,31 @@ export const IModelGrid = ({ + 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 ? ( @@ -313,7 +420,7 @@ export const IModelGrid = ({ ); } diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index be017487..8b83614d 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, @@ -15,8 +16,10 @@ 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?: string | (() => Promise) | undefined; + accessToken?: AccessTokenProvider; sortOptions?: IModelSortOptions; apiOverrides?: ApiOverrides; searchText?: string | undefined; @@ -28,6 +31,7 @@ export interface IModelDataHookOptions { export const DEFAULT_PAGE_SIZE = 100; export const useIModelData = ({ + requestType = "", iTwinId, accessToken, sortOptions, @@ -53,7 +57,29 @@ export const useIModelData = ({ IModelSortOptions | undefined >(sortOptions && { ...sortOptions }); const sortDescending = sortOptions?.descending; - const sortedIModels = useIModelSort(iModels, sortOptions); + const sortedIModels = useIModelSort( + iModels, + 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; @@ -82,13 +108,15 @@ 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 - reset(); + if (requestType !== "recents" && requestType !== "favorites") { + reset(); + } }, [ iTwinId, accessToken, @@ -99,6 +127,25 @@ export const useIModelData = ({ searchText, pageSize, maxCount, + requestType, + 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, ]); @@ -147,7 +194,8 @@ export const useIModelData = ({ searchText, pageSize, maxCount, - apiOverrides?.serverEnvironmentPrefix + apiOverrides?.serverEnvironmentPrefix, + requestType ); setAbortController(newAbortController); @@ -181,6 +229,7 @@ export const useIModelData = ({ morePagesAvailable, needsUpdate, page, + requestType, searchText, sortChanged, sortDescending, @@ -188,7 +237,7 @@ export const useIModelData = ({ ]); return { - iModels: sortedIModels, + iModels: filteredIModels, status, fetchMore: morePagesAvailable ? fetchMore : undefined, refetchIModels: reset, @@ -197,14 +246,15 @@ export const useIModelData = ({ const createFetchIModelsFn = ( iTwinId: string, - accessToken: string | (() => Promise), + accessToken: AccessTokenProvider, sortType: string | undefined, sortDescending: boolean, page: number, 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,16 +281,21 @@ 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() - ? `&$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( 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/useIModelFavorites.test.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.test.ts new file mode 100644 index 00000000..4c56c934 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 "./useIModelFavorites"; + +export function mockFetch(data: any, status = 200) { + return jest.fn().mockImplementation(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 () => { + window.fetch = mockFetch({}); + const { result } = renderHook(() => + useIModelFavorites(iTwinId, accessToken) + ); + const iModelId = "test-iModel-id"; + await act(async () => { + await result.current.addIModelToFavorites(iModelId); + }); + expect(result.current.iModelFavorites.has(iModelId)).toBe(true); + + await act(async () => { + 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..bde97048 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelFavorites.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as iModelApi from "../../utils/iModelApi"; + +/** + * Custom hook to manage iModel favorites. + * @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. + */ +export const useIModelFavorites = ( + iTwinId: string | undefined, + accessToken: AccessTokenProvider | 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; + } + try { + await iModelApi.addIModelToFavorites({ + iModelId, + accessToken, + serverEnvironmentPrefix, + }); + + 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; + } + try { + await iModelApi.removeIModelFromFavorites({ + iModelId, + accessToken, + serverEnvironmentPrefix, + }); + + setIModelFavorites((prev) => { + const newFavorites = new Set(prev); + newFavorites.delete(iModelId); + return newFavorites; + }); + } catch (error) { + console.error(error); + } + }, + [accessToken, 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 { + 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 instanceof Error && error.name === "AbortError") { + return; + } + console.error(error); + } + }; + void fetchIModelFavorites(controller.signal); + + return () => { + controller.abort(); + }; + }, [iTwinId, accessToken, serverEnvironmentPrefix]); + + return { + iModelFavorites, + addIModelToFavorites, + removeIModelFromFavorites, + }; +}; diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts index aed26d8a..62d95295 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelSort.ts @@ -32,7 +32,9 @@ const sortStringValues = (a: string, b: string) => a.localeCompare(b); export const useIModelSort = ( iModels: IModelFull[], - options?: IModelSortOptions + options?: IModelSortOptions, + // needed to account for limitation of react hooks + enableSort = true ) => { const sortType = typeof options !== "function" ? options?.sortType : undefined; @@ -40,6 +42,9 @@ export const useIModelSort = ( typeof options !== "function" ? options?.descending ?? false : undefined; const sortFn = typeof options === "function" ? options : undefined; return React.useMemo(() => { + if (!enableSort) { + return iModels; + } if (sortFn) { return [...iModels].sort(sortFn); } @@ -61,5 +66,5 @@ export const useIModelSort = ( } ); return descending ? sorted.reverse() : sorted; - }, [sortFn, sortType, iModels, descending]); + }, [enableSort, sortFn, sortType, iModels, 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..9c10bb12 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/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.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 09242d50..5a0ba12d 100644 --- a/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx +++ b/packages/modules/imodel-browser/src/containers/iModelTiles/IModelTile.tsx @@ -5,12 +5,16 @@ import { Tile } from "@itwin/itwinui-react"; import React from "react"; -import { ApiOverrides, IModelFull } from "../../types"; +import { TileFavoriteIcon } from "../../components/tileFavoriteIcon/TileFavoriteIcon"; +import { useIModelFavoritesContext } from "../../contexts/IModelFavoritesContext"; +import { AccessTokenProvider, ApiOverrides, IModelFull } from "../../types"; +import { _mergeStrings } from "../../utils/_apiOverrides"; import { _buildManagedContextMenuOptions, ContextMenuBuilderItem, } from "../../utils/_buildMenuOptions"; import { IModelThumbnail } from "../iModelThumbnail/IModelThumbnail"; +import styles from "./IModelTile.module.scss"; type TileProps = React.ComponentPropsWithoutRef; @@ -18,11 +22,18 @@ 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 */ 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 } @@ -33,6 +44,8 @@ export interface IModelTileProps { refetchIModels?: () => void; /** Indicates whether the tile should take the full width of its container */ fullWidth?: boolean; + /** Hides the favorite icon when true */ + hideFavoriteIcon?: boolean; } /** @@ -45,8 +58,10 @@ export const IModelTile = ({ onThumbnailClick, apiOverrides, tileProps, + stringsOverrides, refetchIModels, fullWidth, + hideFavoriteIcon, }: IModelTileProps) => { const { name, @@ -62,10 +77,19 @@ export const IModelTile = ({ buttons, moreOptions, isDisabled, - onClick, + onClick: tilePropsOnClick, metadata, + className = "", ...rest } = tileProps ?? {}; + const favoritesContext = useIModelFavoritesContext(); + const strings = _mergeStrings( + { + addToFavorites: "Add to favorites", + removeFromFavorites: "Remove from favorites", + }, + stringsOverrides + ); const moreOptionsBuilt = React.useMemo( () => @@ -93,13 +117,15 @@ export const IModelTile = ({ isLoading={isLoading} status={status} isDisabled={isDisabled} - style={fullWidth ? { width: "100%" } : undefined} + className={`${styles.iModelTile} ${ + fullWidth ? styles.fullWidth : "" + } ${className}`} {...rest} > onClick?.(e) ?? onThumbnailClick?.(iModel)} + onClick={(e) => tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel)} aria-disabled={isDisabled} data-testid={`iModel-tile-${iModel.id}-name-label`} > @@ -108,7 +134,21 @@ export const IModelTile = ({ {leftIcon && {leftIcon}} - {rightIcon && {rightIcon}} + + {rightIcon} + {!hideFavoriteIcon && favoritesContext && ( + favoritesContext.add(iModel.id)} + onRemoveFromFavorites={() => favoritesContext.remove(iModel.id)} + addLabel={strings.addToFavorites} + removeLabel={strings.removeFromFavorites} + className={`${styles.iModelTileFavoriteIcon} ${ + !favoritesContext.favorites.has(iModel.id) && styles.hidden + }`} + /> + )} + {thumbnail ? ( {thumbnail} ) : ( @@ -126,7 +166,7 @@ export const IModelTile = ({ onClick?.(e) ?? onThumbnailClick?.(iModel)} + onClick={(e) => tilePropsOnClick?.(e) ?? onThumbnailClick?.(iModel)} 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..fb6aa6d7 --- /dev/null +++ b/packages/modules/imodel-browser/src/contexts/IModelFavoritesContext.tsx @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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/useIModelFavorites"; +import { AccessTokenProvider } from "../types"; + +export interface IModelFavoritesContextValue { + favorites: Set; + add: (iModelId: string) => Promise; + remove: (iModelId: string) => Promise; +} + +export const IModelFavoritesContext = React.createContext< + IModelFavoritesContextValue | undefined +>(undefined); + +export interface IModelFavoritesProviderProps { + iTwinId: string | undefined; + accessToken?: AccessTokenProvider; + serverEnvironmentPrefix?: string; + children: React.ReactNode; +} + +export const IModelFavoritesProvider = ({ + iTwinId, + accessToken, + serverEnvironmentPrefix, + children, +}: IModelFavoritesProviderProps) => { + const { iModelFavorites, addIModelToFavorites, removeIModelFromFavorites } = + useIModelFavorites( + iTwinId, + accessToken, + serverEnvironmentPrefix === "dev" || serverEnvironmentPrefix === "qa" + ? serverEnvironmentPrefix + : undefined + ); + + const value = React.useMemo( + () => ({ + favorites: iModelFavorites, + add: addIModelToFavorites, + remove: removeIModelFromFavorites, + }), + [iModelFavorites, addIModelToFavorites, removeIModelFromFavorites] + ); + + return ( + + {children} + + ); +}; + +export const useIModelFavoritesContext = () => { + const ctx = React.useContext(IModelFavoritesContext); + if (!ctx) { + console.warn( + "useIModelFavoritesContext must be used within IModelFavoritesProvider" + ); + } + return ctx; +}; 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 new file mode 100644 index 00000000..b00c983c --- /dev/null +++ b/packages/modules/imodel-browser/src/utils/iModelApi.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AccessTokenProvider } from "../types"; +import { _getAPIServer } from "./_apiOverrides"; + +/** 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; + 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)}`; + + await 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); + } +} + +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`); + } +}