Skip to content

Commit

Permalink
Add favorites functionality to ITwinGrid and ITwinTile components (#129)
Browse files Browse the repository at this point in the history
  • Loading branch information
arome authored Nov 21, 2024
1 parent dca6564 commit 4d548dc
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 1 addition & 6 deletions packages/apps/storybook/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -107,6 +108,9 @@ export const ITwinGrid = ({
postProcessCallback,
viewMode,
}: ITwinGridProps) => {
const { iTwinFavorites, addITwinToFavorites, removeITwinFromFavorites } =
useITwinFavorites(accessToken, apiOverrides);

const strings = _mergeStrings(
{
tableColumnName: "iTwin Number",
Expand Down Expand Up @@ -183,6 +187,9 @@ export const ITwinGrid = ({
iTwinOptions={iTwinActions}
onThumbnailClick={onThumbnailClick}
useTileState={useIndividualState}
isFavorite={iTwinFavorites.has(iTwin.id)}
addToFavorites={addITwinToFavorites}
removeFromFavorites={removeITwinFromFavorites}
{...tileOverrides}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -31,6 +32,12 @@ export interface ITwinTileProps {
};
/** Tile props that will be applied after normal use. (Will override ITwinTile if used) */
tileProps?: Partial<TileProps>;
/** Indicates whether the iTwin is marked as a favorite */
isFavorite?: boolean;
/** Function to add the iTwin to favorites */
addToFavorites?(iTwinId: string): Promise<void>;
/** Function to remove the iTwin from favorites */
removeFromFavorites?(iTwinId: string): Promise<void>;
}

/**
Expand All @@ -42,6 +49,9 @@ export const ITwinTile = ({
onThumbnailClick,
tileProps,
stringsOverrides,
isFavorite,
addToFavorites,
removeFromFavorites,
}: ITwinTileProps) => {
const strings = _mergeStrings(
{
Expand Down Expand Up @@ -86,6 +96,18 @@ export const ITwinTile = ({
<ITwinIcon />
</span>
}
rightIcon={
<IconButton
onClick={async () => {
isFavorite
? await removeFromFavorites?.(iTwin.id)
: await addToFavorites?.(iTwin.id);
}}
styleType="borderless"
>
{isFavorite ? <SvgStar /> : <SvgStarHollow />}
</IconButton>
}
{...(tileProps ?? {})}
/>
</ThemeProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>) | 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<ITwinFull[]>} [apiOverrides] - Optional API overrides.
* @returns {object} - An object containing:
* - {Set<string>} 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<string>) | undefined,
apiOverrides?: ApiOverrides<ITwinFull[]>
): {
iTwinFavorites: Set<string>;
addITwinToFavorites: (iTwinId: string) => Promise<void>;
removeITwinFromFavorites: (iTwinId: string) => Promise<void>;
} => {
const [iTwinFavorites, setITwinFavorites] = useState(new Set<string>());

/**
* Adds an iTwin to the favorites.
* @param {string} iTwinId - The ID of the iTwin to add to favorites.
* @returns {Promise<void>}
*/
const addITwinToFavorites = useCallback(
async (iTwinId: string): Promise<void> => {
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<void>}
*/
const removeITwinFromFavorites = useCallback(
async (iTwinId: string): Promise<void> => {
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<ITwinFavorites[]>} - 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<ITwinFavorites[]> => {
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;
}

0 comments on commit 4d548dc

Please sign in to comment.