Skip to content

Commit

Permalink
Fix iTwin favorites (#130)
Browse files Browse the repository at this point in the history
* do not throw error when status is 204

* Fix bug in ITwin favorites functionality

* Remove 204 status check in ITwin favorites fetch logic

* Handle AbortError in ITwin favorites fetching logic

* Add label to favorites button in ITwinTile component

* Add favorites functionality to ITwinGrid table view

* Fix bug in ITwin favorites and add favorites to ITwinGrid table view

* Add unit tests for useITwinFavorites hook
  • Loading branch information
arome authored Dec 4, 2024
1 parent 376ab43 commit 1d8060b
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/imodel-browser-react",
"comment": "Fix bug in itwin favorites and add favorites to iTwinGrid table view",
"type": "none"
}
],
"packageName": "@itwin/imodel-browser-react"
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ export type IndividualITwinStateHook = (
}
) => Partial<ITwinTileProps>;

export interface ITwinGridStrings {
/** Displayed for table favorites header. */
tableColumnFavorites: string;
/** Displayed for table name header. */
tableColumnName: string;
/** Displayed for table description header. */
tableColumnDescription: string;
/** Displayed for table lastModified header. */
tableColumnLastModified: string;
/** Displayed on table while loading data. */
tableLoadingData: string;
/** Badge text for trial iTwins */
trialBadge: string;
/** Badge text for inactive iTwins */
inactiveBadge: string;
/** Displayed after successful fetch, but no iTwins are returned. */
noITwins: string;
/** Displayed when the component is mounted but the accessToken is empty. */
noAuthentication: string;
/** Generic message displayed if an error occurs while fetching. */
error: string;
/** Accessible text for the hollow star icon to add the iTwin to favorites */
addToFavorites: string;
/** Accessible text for the full star icon to remove the iTwin from favorites */
removeFromFavorites: string;
}

export interface ITwinGridProps {
/** Access token that requires the `itwins:read` scope. Provide a function that returns the token to prevent the token from expiring. */
accessToken?: string | (() => Promise<string>) | undefined;
Expand All @@ -54,26 +81,7 @@ export interface ITwinGridProps {
/** Static props to apply over each tile, mainly used for tileProps, overrides ITwinGrid provided values */
tileOverrides?: Partial<ITwinTileProps>;
/** Strings displayed by the browser */
stringsOverrides?: {
/** Displayed for table name header. */
tableColumnName?: string;
/** Displayed for table description header. */
tableColumnDescription?: string;
/** Displayed for table lastModified header. */
tableColumnLastModified?: string;
/** Displayed on table while loading data. */
tableLoadingData?: string;
/** Badge text for trial iTwins */
trialBadge?: string;
/** Badge text for inactive iTwins */
inactiveBadge?: string;
/** Displayed after successful fetch, but no iTwins are returned. */
noITwins?: string;
/** Displayed when the component is mounted but the accessToken is empty. */
noAuthentication?: string;
/** Generic message displayed if an error occurs while fetching. */
error?: string;
};
stringsOverrides?: Partial<ITwinGridStrings>;
/** Object that configures different overrides for the API.
* @property `data`: Array of iTwins used in the grid.
* @property `serverEnvironmentPrefix`: Either qa or dev.
Expand Down Expand Up @@ -108,11 +116,17 @@ export const ITwinGrid = ({
postProcessCallback,
viewMode,
}: ITwinGridProps) => {
const { iTwinFavorites, addITwinToFavorites, removeITwinFromFavorites } =
useITwinFavorites(accessToken, apiOverrides);
const {
iTwinFavorites,
addITwinToFavorites,
removeITwinFromFavorites,
shouldRefetchFavorites,
resetShouldRefetchFavorites,
} = useITwinFavorites(accessToken, apiOverrides);

const strings = _mergeStrings(
{
tableColumnFavorites: "",
tableColumnName: "iTwin Number",
tableColumnDescription: "iTwin Name",
tableColumnLastModified: "Last Modified",
Expand All @@ -122,6 +136,8 @@ export const ITwinGrid = ({
noITwins: "No iTwin found.",
noAuthentication: "No access token provided",
error: "An error occurred",
addToFavorites: "Add to favorites",
removeFromFavorites: "Remove from favorites",
},
stringsOverrides
);
Expand All @@ -135,6 +151,8 @@ export const ITwinGrid = ({
accessToken,
apiOverrides,
filterOptions,
shouldRefetchFavorites,
resetShouldRefetchFavorites,
});

const iTwins = React.useMemo(
Expand All @@ -147,6 +165,9 @@ export const ITwinGrid = ({
iTwinActions,
onThumbnailClick,
strings,
iTwinFavorites,
addITwinToFavorites,
removeITwinFromFavorites,
});

const noResultsText = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface ITwinTileProps {
trialBadge?: string;
/** Badge text for inactive iTwins */
inactiveBadge?: string;
/** Accessible text for the hollow star icon to add the iTwin to favorites */
addToFavorites?: string;
/** Accessible text for the full star icon to remove the iTwin from favorites */
removeFromFavorites?: string;
/** Accessible text for the thumbnail icon to navigate to the iTwin */
navigateToITwin?: string;
};
/** Tile props that will be applied after normal use. (Will override ITwinTile if used) */
tileProps?: Partial<TileProps>;
Expand Down Expand Up @@ -57,6 +63,9 @@ export const ITwinTile = ({
{
trialBadge: "Trial",
inactiveBadge: "Inactive",
addToFavorites: "Add to favorites",
removeFromFavorites: "Remove from favorites",
navigateToITwin: "Navigate to iTwin",
},
stringsOverrides
);
Expand Down Expand Up @@ -89,15 +98,19 @@ export const ITwinTile = ({
}
moreOptions={moreOptions}
thumbnail={
<span
<div
aria-label={onThumbnailClick ? strings.navigateToITwin : ""}
onClick={() => onThumbnailClick?.(iTwin)}
style={{ cursor: onThumbnailClick ? "pointer" : "auto" }}
>
<ITwinIcon />
</span>
</div>
}
rightIcon={
<IconButton
aria-label={
isFavorite ? strings.removeFromFavorites : strings.addToFavorites
}
onClick={async () => {
isFavorite
? await removeFromFavorites?.(iTwin.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface ProjectDataHookOptions {
accessToken?: string | (() => Promise<string>) | undefined;
apiOverrides?: ApiOverrides<ITwinFull[]>;
filterOptions?: ITwinFilterOptions;
shouldRefetchFavorites?: boolean;
resetShouldRefetchFavorites?: () => void;
}

const PAGE_SIZE = 100;
Expand All @@ -30,6 +32,8 @@ export const useITwinData = ({
accessToken,
apiOverrides,
filterOptions,
shouldRefetchFavorites,
resetShouldRefetchFavorites,
}: ProjectDataHookOptions) => {
const data = apiOverrides?.data;
const [projects, setProjects] = React.useState<ITwinFull[]>([]);
Expand Down Expand Up @@ -103,6 +107,10 @@ export const useITwinData = ({
const options: RequestInit = {
signal: abortController.signal,
headers: {
"Cache-Control":
requestType === "favorites" && shouldRefetchFavorites
? "no-cache"
: "",
Authorization:
typeof accessToken === "function"
? await accessToken()
Expand All @@ -119,6 +127,7 @@ export const useITwinData = ({
throw new Error(errorText);
});
setStatus(DataStatus.Complete);
requestType === "favorites" && resetShouldRefetchFavorites?.();
if (result.iTwins.length !== PAGE_SIZE) {
setMorePages(false);
}
Expand Down Expand Up @@ -148,6 +157,8 @@ export const useITwinData = ({
page,
morePages,
iTwinSubClass,
shouldRefetchFavorites,
resetShouldRefetchFavorites,
]);
return {
iTwins: filteredProjects,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*---------------------------------------------------------------------------------------------
* 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 { useITwinFavorites } from "./useITwinFavorites";

export function mockFetch(data: any, status = 200) {
return jest.fn().mockImplementationOnce(async () =>
Promise.resolve({
status,
ok: true,
json: () => data,
})
);
}

const accessToken = "test-access-token";

describe("useITwinFavorites", () => {
// Clear mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});

test("should initialize with empty Set", () => {
const { result } = renderHook(() => useITwinFavorites(accessToken));
window.fetch = mockFetch({ iTwins: [] });

expect(result.current.iTwinFavorites).toBeInstanceOf(Set);
expect(result.current.iTwinFavorites.size).toBe(0);
});

test("should fetch favorites on mount", async () => {
const mockFavorites = [
{ id: "1", name: "iTwin1" },
{ id: "2", name: "iTwin2" },
];

window.fetch = mockFetch({ iTwins: mockFavorites });
const { result, waitForNextUpdate } = renderHook(() =>
useITwinFavorites(accessToken)
);

await waitForNextUpdate();
expect(result.current.iTwinFavorites.has("1")).toBe(true);
expect(result.current.iTwinFavorites.has("2")).toBe(true);
expect(result.current.iTwinFavorites.size).toBe(2);
});

test("should handle empty response from API", async () => {
window.fetch = mockFetch({ iTwins: [] });

const { result, waitForNextUpdate } = renderHook(() =>
useITwinFavorites(accessToken)
);

await waitForNextUpdate();
expect(result.current.iTwinFavorites.size).toBe(0);
});

test("should add and remove an iTwin from favorites", async () => {
const { result } = renderHook(() => useITwinFavorites(accessToken));
const iTwinId = "test-itwin-id";
await act(async () => {
window.fetch = mockFetch({});
await result.current.addITwinToFavorites(iTwinId);
});
expect(result.current.iTwinFavorites.has(iTwinId)).toBe(true);

await act(async () => {
window.fetch = mockFetch({});
await result.current.removeITwinFromFavorites(iTwinId);
});
expect(result.current.iTwinFavorites.has(iTwinId)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const HOOK_ABORT_ERROR =
* - {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.
* - {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 useITwinFavorites = (
accessToken: string | (() => Promise<string>) | undefined,
Expand All @@ -27,8 +29,11 @@ export const useITwinFavorites = (
iTwinFavorites: Set<string>;
addITwinToFavorites: (iTwinId: string) => Promise<void>;
removeITwinFromFavorites: (iTwinId: string) => Promise<void>;
shouldRefetchFavorites: boolean;
resetShouldRefetchFavorites: () => void;
} => {
const [iTwinFavorites, setITwinFavorites] = useState(new Set<string>());
const [shouldRefetchFavorites, setShouldRefetchFavorites] = useState(false);

/**
* Adds an iTwin to the favorites.
Expand All @@ -52,10 +57,13 @@ export const useITwinFavorites = (
Accept: "application/vnd.bentley.itwin-platform.v1+json",
},
});
if (!result || result.status !== 200) {

if (!result || (result.status !== 200 && result.status !== 204)) {
throw new Error(`Failed to add iTwin ${iTwinId} to favorites`);
}

setITwinFavorites((prev) => new Set([...prev, iTwinId]));
setShouldRefetchFavorites(true);
} catch (error) {
console.error(error);
}
Expand Down Expand Up @@ -86,7 +94,7 @@ export const useITwinFavorites = (
},
});

if (!result || result.status !== 200) {
if (!result || (result.status !== 200 && result.status !== 204)) {
throw new Error(`Failed to remove iTwin ${iTwinId} to favorites`);
}

Expand All @@ -95,6 +103,7 @@ export const useITwinFavorites = (
newFavorites.delete(iTwinId);
return newFavorites;
});
setShouldRefetchFavorites(true);
} catch (error) {
console.error(error);
}
Expand All @@ -118,6 +127,7 @@ export const useITwinFavorites = (
)}/itwins/favorites?subClass=Project`;
const result = await fetch(url, {
headers: {
"Cache-Control": shouldRefetchFavorites ? "no-cache" : "",
authorization:
typeof accessToken === "function"
? await accessToken()
Expand All @@ -142,9 +152,13 @@ export const useITwinFavorites = (
const response: ITwinFavoritesResponse = await result.json();
return response.iTwins;
},
[accessToken, apiOverrides]
[accessToken, apiOverrides, shouldRefetchFavorites]
);

const resetShouldRefetchFavorites = useCallback(() => {
setShouldRefetchFavorites(false);
}, []);

useEffect(() => {
const controller = new AbortController();
/**
Expand All @@ -156,7 +170,10 @@ export const useITwinFavorites = (
const favorites = await getITwinFavorites(abortSignal);
setITwinFavorites(new Set(favorites.map((favorite) => favorite.id)));
} catch (error) {
if (error === HOOK_ABORT_ERROR) {
if (
error === HOOK_ABORT_ERROR ||
(error instanceof Error && error.name === "AbortError")
) {
return;
}
console.error(error);
Expand All @@ -169,7 +186,13 @@ export const useITwinFavorites = (
};
}, [getITwinFavorites]);

return { iTwinFavorites, addITwinToFavorites, removeITwinFromFavorites };
return {
iTwinFavorites,
addITwinToFavorites,
removeITwinFromFavorites,
shouldRefetchFavorites,
resetShouldRefetchFavorites,
};
};

/** Response from https://developer.bentley.com/apis/iTwins/operations/get-my-favorite-itwins/ */
Expand Down
Loading

0 comments on commit 1d8060b

Please sign in to comment.