Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add favorites functionality to ITwinGrid and ITwinTile components #129

Merged
merged 8 commits into from
Nov 21, 2024
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)));
aruniverse marked this conversation as resolved.
Show resolved Hide resolved
} 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;
}
Loading