diff --git a/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json b/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json new file mode 100644 index 00000000..c574d20b --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json @@ -0,0 +1,15 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Add onDataStateChange callback to ITwinGrid", + "type": "minor" + }, + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Report `fetching` rather than `undefined` for the iTwin grid's status on the first render, which `postProcessCallback` receives as its second argument", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} diff --git a/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx index f5f36557..f942057f 100644 --- a/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx @@ -23,6 +23,7 @@ import { } from "@itwin/itwinui-react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React, { PropsWithChildren } from "react"; +import { fn } from "storybook/test"; import { accessTokenArgTypes } from "../utils/storyHelp"; @@ -58,6 +59,7 @@ export default { args: { apiOverrides: { serverEnvironmentPrefix: "qa" }, requestType: "all", + onDataStateChange: fn(), }, excludeStories: ["ITwinGrid"], diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 022b3272..6bfe96ad 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { type IndividualITwinStateHook, + type ITwinDataQuery, + type ITwinDataState, type ITwinFull, type ITwinGridProps, DataStatus, @@ -21,6 +23,7 @@ import Typography from "@mui/material/Typography"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React from "react"; import { action } from "storybook/actions"; +import { fn } from "storybook/test"; import bridgeThumbnail from "../../utils/bridge.jpg"; import nightThumbnail from "../../utils/night.jpg"; @@ -266,6 +269,92 @@ export const WithPostProcessCallback: StoryObj = { }, }; +const describeQuery = (query: ITwinDataQuery) => + `${query.requestType || "all"}${ + query.filterText ? ` "${query.filterText}"` : "" + }`; + +const WithOnDataStateChangeRender = ({ + onDataStateChange: reportToActionsPanel, + ...args +}: ITwinGridProps) => { + const [log, setLog] = React.useState([]); + const fetchStartedAt = React.useRef>({}); + + const onDataStateChange = React.useCallback( + (state: ITwinDataState) => { + reportToActionsPanel?.(state); + + const query = describeQuery(state.query); + const append = (line: string) => + setLog((log) => [`${query}: ${line}`, ...log].slice(0, 12)); + + if (state.status === DataStatus.Fetching) { + fetchStartedAt.current[query] = performance.now(); + append("fetching"); + return; + } + + const startedAt = fetchStartedAt.current[query]; + const timing = + startedAt === undefined + ? "from the iTwins already loaded" + : `after ${Math.round(performance.now() - startedAt)}ms`; + append( + [ + `${state.status} ${timing}`, + `${state.iTwins.length} iTwins`, + state.hasMore ? "more pages remain" : undefined, + state.error ? String(state.error) : undefined, + ] + .filter(Boolean) + .join(", ") + ); + }, + [reportToActionsPanel] + ); + + return ( +
+ + Property onDataStateChange reports the grid's data + state as it changes, newest first, and sends each report to the Actions + panel. Search to see a query reported before its result. The favorites + and recents tabs answer a search from the iTwins they already hold, so + they report a result with no fetch before it. + + + {log.length === 0 ? ( + Nothing reported yet. + ) : ( + log.map((line, index) => ( + + {line} + + )) + )} + + +
+ ); +}; + +export const WithOnDataStateChange: StoryObj = { + render: (args) => , + args: { + apiOverrides: { serverEnvironmentPrefix: "qa" }, + }, +}; + export const FetchAllSubclasses: StoryObj = { args: { apiOverrides: { serverEnvironmentPrefix: "qa" }, @@ -428,6 +517,7 @@ export default { }, args: { requestType: "all", + onDataStateChange: fn(), }, excludeStories: ["ITwinGrid"], } as Meta; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx index 288fd506..9bfd6683 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx @@ -66,6 +66,16 @@ describe("ITwinGrid", () => { expect(wrapper.getAllByRole("row").length).toEqual(3); // First row is header }); + it("should hand onDataStateChange to the data hook", () => { + const onDataStateChange = jest.fn(); + + render(); + + expect(useITwinData.useITwinData).toHaveBeenCalledWith( + expect.objectContaining({ onDataStateChange }) + ); + }); + it("should not refetch iTwins favorites when component rerenders", async () => { // Arrange jest.spyOn(useITwinData, "useITwinData").mockReturnValue({ diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index 1d6bc0c3..c6f23b01 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -16,6 +16,7 @@ import { ApiOverrides, DataStatus, ITwinCellOverrides, + ITwinDataState, ITwinFilterOptions, ITwinFull, ITwinSubClass, @@ -106,6 +107,11 @@ export interface ITwinGridProps { fetchStatus: DataStatus | undefined, totalCount: number | undefined ) => ITwinFull[]; + /** + * Called with the grid's data state when it changes: once for each query, then for each result it + * returns. Does not need to be memoized. + */ + onDataStateChange?: (state: ITwinDataState) => void; /**iTwin view mode */ viewMode?: ViewType; /** Overrides for cell rendering in cells viewMode */ @@ -141,6 +147,7 @@ const ITwinGridInternal = ({ tileOverrides, useIndividualState, postProcessCallback, + onDataStateChange, viewMode, cellOverrides, className, @@ -191,6 +198,7 @@ const ITwinGridInternal = ({ orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, + onDataStateChange, }); const iTwins = React.useMemo( diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts index 8302db4e..8876e278 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts @@ -10,9 +10,9 @@ import { rest, } from "msw"; -import { deferred, responseFor } from "../../tests/helpers"; +import { deferred, ids, responseFor } from "../../tests/helpers"; import { server } from "../../tests/mocks/server"; -import { DataStatus } from "../../types"; +import { DataStatus, ITwinDataState } from "../../types"; import { useITwinData } from "./useITwinData"; describe("useITwinData hook", () => { @@ -827,4 +827,217 @@ describe("useITwinData hook", () => { expect(requests).toEqual(2); }); }); + + describe("onDataStateChange", () => { + const reportsOf = (states: ITwinDataState[]) => + states.map((state) => [ + state.query.filterText, + state.status, + state.iTwins.map(ids), + ]); + + it("reports the query it is fetching, then the result of that query", async () => { + const states: ITwinDataState[] = []; + + const { waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, onDataStateChange: (s) => states.push(s) }) + ); + await waitForNextUpdate(); + + expect(reportsOf(states)).toEqual([ + ["", DataStatus.Fetching, []], + ["", DataStatus.Complete, ["my1"]], + ]); + expect(states[0].query).toEqual({ + requestType: "", + filterText: "", + iTwinSubClass: "Project", + orderby: undefined, + }); + }); + + it("never reports a settled status against a query that has not run", async () => { + const states: ITwinDataState[] = []; + const onDataStateChange = (state: ITwinDataState) => states.push(state); + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [{ accessToken, onDataStateChange }], + }); + await waitForNextUpdate(); + + rerender([{ accessToken, onDataStateChange, filterOptions: "searched" }]); + await waitForNextUpdate(); + + expect(reportsOf(states)).toEqual([ + ["", DataStatus.Fetching, []], + ["", DataStatus.Complete, ["my1"]], + ["searched", DataStatus.Fetching, []], + ["searched", DataStatus.Complete, ["mySearched1"]], + ]); + }); + + it("reports the new query for a filter change that only client side filtering handles", async () => { + const favorites = [ + { id: "fav1", displayName: "alpha" }, + { id: "fav2", displayName: "beta" }, + ]; + server.use( + rest.get("https://api.bentley.com/itwins/favorites", (_req, res, ctx) => + res(ctx.status(200), ctx.json({ iTwins: favorites })) + ) + ); + const states: ITwinDataState[] = []; + const onDataStateChange = (state: ITwinDataState) => states.push(state); + + const { rerender, waitFor, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [ + { accessToken, requestType: "favorites" as const, onDataStateChange }, + ], + }); + await waitForNextUpdate(); + + rerender([ + { + accessToken, + requestType: "favorites" as const, + onDataStateChange, + filterOptions: "alpha", + }, + ]); + await waitFor(() => states.length >= 3); + + expect(reportsOf(states)).toEqual([ + ["", DataStatus.Fetching, []], + ["", DataStatus.Complete, ["fav1", "fav2"]], + ["alpha", DataStatus.Complete, ["fav1"]], + ]); + }); + + it("reports a subclass change as a different query", async () => { + server.use( + rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + iTwins: [{ id: req.url.searchParams.get("subClass") ?? "none" }], + }) + ) + ) + ); + const states: ITwinDataState[] = []; + const onDataStateChange = (state: ITwinDataState) => states.push(state); + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [{ accessToken, onDataStateChange }], + }); + await waitForNextUpdate(); + + rerender([ + { accessToken, onDataStateChange, iTwinSubClass: "Asset" as const }, + ]); + await waitForNextUpdate(); + + expect( + states.map((state) => [ + state.query.iTwinSubClass, + state.status, + state.iTwins.map(ids), + ]) + ).toEqual([ + ["Project", DataStatus.Fetching, []], + ["Project", DataStatus.Complete, ["Project"]], + ["Asset", DataStatus.Fetching, []], + ["Asset", DataStatus.Complete, ["Asset"]], + ]); + }); + + it("reports hasMore until a partial page comes back", async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + id: `first-${i}`, + })); + server.use( + rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + iTwins: + req.url.searchParams.get("$skip") === "0" + ? fullPage + : [{ id: "second-0" }], + }) + ) + ) + ); + const states: ITwinDataState[] = []; + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, onDataStateChange: (s) => states.push(s) }) + ); + await waitForNextUpdate(); + expect(states[states.length - 1].hasMore).toBe(true); + + act(() => { + result.current.fetchMore?.(); + }); + await waitForNextUpdate(); + + const last = states[states.length - 1]; + expect(last.hasMore).toBe(false); + expect(last.iTwins).toHaveLength(101); + }); + + it("reports the failure that produced an error status", async () => { + server.use( + rest.get("https://api.bentley.com/itwins/", (_req, res, ctx) => + res(ctx.status(401), ctx.text("no soup for you")) + ) + ); + const states: ITwinDataState[] = []; + + const { waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, onDataStateChange: (s) => states.push(s) }) + ); + await waitForNextUpdate(); + + const last = states[states.length - 1]; + expect(last.status).toEqual(DataStatus.FetchFailed); + expect(last.error).toEqual(new Error("no soup for you")); + }); + + it("does not report again for a callback that changed identity", async () => { + const states: ITwinDataState[] = []; + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [ + { + accessToken, + onDataStateChange: (s: ITwinDataState) => states.push(s), + }, + ], + }); + await waitForNextUpdate(); + expect(states).toHaveLength(2); + + rerender([ + { + accessToken, + onDataStateChange: (s: ITwinDataState) => states.push(s), + }, + ]); + + expect(states).toHaveLength(2); + }); + }); }); diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index 507e0b43..8439c01c 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -8,13 +8,14 @@ import { useLogger } from "../../contexts/LoggerContext"; import { AccessTokenProvider, ApiOverrides, - DataStatus, + ITwinDataQuery, + ITwinDataState, ITwinFilterOptions, ITwinFull, ITwinSubClass, } from "../../types"; import { _getAPIServer } from "../../utils/_apiOverrides"; -import { useITwinFilter } from "./useITwinFilter"; +import { useITwinDataState } from "./useITwinDataState"; export interface ProjectDataHookOptions { requestType?: "favorites" | "recents" | ""; @@ -25,10 +26,14 @@ export interface ProjectDataHookOptions { orderbyOptions?: string; shouldRefetchFavorites?: boolean; resetShouldRefetchFavorites?: () => void; + onDataStateChange?: (state: ITwinDataState) => void; } const PAGE_SIZE = 100; +const isClientSideFiltered = (requestType: string) => + ["favorites", "recents"].includes(requestType); + export const useITwinData = ({ requestType = "", iTwinSubClass = "Project", @@ -38,26 +43,43 @@ export const useITwinData = ({ orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, + onDataStateChange, }: ProjectDataHookOptions) => { const logger = useLogger(); const data = apiOverrides?.data; const serverEnvironmentPrefix = apiOverrides?.serverEnvironmentPrefix; - const [projects, setProjects] = React.useState([]); - const [status, setStatus] = React.useState(); const [totalCount, setTotalCount] = React.useState(); - const filteredProjects = useITwinFilter(projects, filterOptions); const [page, setPage] = React.useState(0); - const [morePages, setMorePages] = React.useState(true); + + const query = React.useMemo( + () => ({ + requestType, + filterText: filterOptions ?? "", + iTwinSubClass, + orderby: orderbyOptions, + }), + [requestType, filterOptions, iTwinSubClass, orderbyOptions] + ); + const { + status, + iTwins, + hasMore, + reset, + applyQuery, + markFetching, + pageLoaded, + pageFailed, + tokenRequired, + dataProvided, + } = useITwinDataState(query, onDataStateChange); const resetData = React.useCallback(() => { - setStatus(DataStatus.Fetching); - setProjects([]); + reset(); setTotalCount(undefined); setPage(0); - setMorePages(true); fetchingMoreRef.current = true; lastPageFailedRef.current = false; - }, []); + }, [reset]); // We start in a fetching state const fetchingMoreRef = React.useRef(true); @@ -86,22 +108,21 @@ export const useITwinData = ({ const activeRequestRef = React.useRef(undefined); - const morePagesRef = React.useRef(morePages); + const morePagesRef = React.useRef(hasMore); React.useEffect(() => { - morePagesRef.current = morePages; - }, [morePages]); + morePagesRef.current = hasMore; + }, [hasMore]); React.useEffect(() => { // If filter changes but we already have all the data for favorites or recents, // let client side filtering do its job, otherwise, refetch from scratch. // Use ref so "morePages" changes itself does not trigger the effect. - if ( - morePagesRef.current || - !["favorites", "recents"].includes(requestType) - ) { + if (morePagesRef.current || !isClientSideFiltered(requestType)) { resetData(); + } else { + applyQuery(query); } - }, [filterOptions, requestType, resetData]); + }, [query, requestType, resetData, applyQuery]); React.useEffect(() => { // If any of the dependencies change, always restart the fetch from scratch. @@ -117,98 +138,55 @@ export const useITwinData = ({ ]); React.useEffect(() => { - if (!morePages) { + if (!hasMore) { return; } if (data) { - setProjects(data); - setStatus(DataStatus.Complete); - setMorePages(false); + dataProvided(data); return; } if (!accessToken) { - setStatus(DataStatus.TokenRequired); - setProjects([]); + tokenRequired(); return; } if (page === 0) { - setStatus(DataStatus.Fetching); + markFetching(); } const requestId = Symbol(); activeRequestRef.current = requestId; - const abortController = new AbortController(); - const endpoint = ["favorites", "recents"].includes(requestType) - ? requestType - : ""; - const resolvedITwinSubClass = iTwinSubClass === "All" ? "" : iTwinSubClass; - const subClass = `?subClass=${resolvedITwinSubClass}`; - const paging = `&$skip=${page * PAGE_SIZE}&$top=${PAGE_SIZE}`; - const search = - ["favorites", "recents"].includes(requestType) || !filterOptions - ? "" - : `&$search=${encodeURIComponent(String(filterOptions).trim())}`; - const orderby = - ["favorites", "recents"].includes(requestType) || !orderbyOptions - ? "" - : `&$orderby=${encodeURIComponent(String(orderbyOptions).trim())}`; - - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/itwins/${endpoint}${subClass}${paging}${search}${orderby}`; - - const makeFetchRequest = async () => { - const options: RequestInit = { - signal: abortController.signal, - headers: { - "Cache-Control": - requestType === "favorites" && shouldRefetchFavorites - ? "no-cache" - : "", - Authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v1+json", - Prefer: "return=representation", - "x-total-count": "true", - }, - }; - - const response = await fetch(url, options); - const result: { iTwins: ITwinFull[] } = response.ok - ? await response.json() - : await response.text().then((errorText) => { - throw new Error(errorText); - }); + const { abortController, fetchITwins } = createFetchITwinsFn({ + query, + accessToken, + page, + serverEnvironmentPrefix, + shouldRefetchFavorites, + }); + + const applyResult = async () => { + const result = await fetchITwins(); if (activeRequestRef.current !== requestId) { return; } - const totalCountHeader = response.headers.get("x-total-count"); - if (totalCountHeader !== null) { - setTotalCount(Number(totalCountHeader)); + if (result.totalCount !== undefined) { + setTotalCount(result.totalCount); } - setStatus(DataStatus.Complete); fetchingMoreRef.current = false; requestType === "favorites" && resetShouldRefetchFavorites?.(); - if (result.iTwins.length !== PAGE_SIZE) { - setMorePages(false); - } - setProjects((projects) => - page === 0 ? result.iTwins : [...projects, ...result.iTwins] - ); + pageLoaded({ + iTwins: result.iTwins, + isFirstPage: page === 0, + hasMore: result.hasMore, + }); }; - makeFetchRequest().catch((e) => { + applyResult().catch((e) => { if (activeRequestRef.current !== requestId || e.name === "AbortError") { // Superseded or aborted, not a failure worth reporting. return; } - if (page === 0) { - setProjects([]); - } - setStatus(DataStatus.FetchFailed); fetchingMoreRef.current = false; lastPageFailedRef.current = true; + pageFailed({ error: e, isFirstPage: page === 0 }); logger.logError("Failed to fetch iTwins", e); }); return () => { @@ -220,22 +198,103 @@ export const useITwinData = ({ requestType, data, serverEnvironmentPrefix, - filterOptions, - orderbyOptions, + query, page, - morePages, + hasMore, refetchCount, retryCount, - iTwinSubClass, shouldRefetchFavorites, resetShouldRefetchFavorites, logger, + dataProvided, + tokenRequired, + markFetching, + pageLoaded, + pageFailed, ]); return { - iTwins: filteredProjects, + iTwins, status, totalCount, - fetchMore: morePages ? fetchMore : undefined, + fetchMore: hasMore ? fetchMore : undefined, refetchITwins, }; }; + +/** + * Builds the request for one page of iTwins. Resolves with the page, or throws what the API + * answered. A totalCount of undefined means the response carried no count, which is not zero. + */ +const createFetchITwinsFn = ({ + query, + accessToken, + page, + serverEnvironmentPrefix, + shouldRefetchFavorites, +}: { + query: ITwinDataQuery; + accessToken: AccessTokenProvider; + page: number; + serverEnvironmentPrefix?: "" | "dev" | "qa"; + shouldRefetchFavorites?: boolean; +}): { + abortController: AbortController; + fetchITwins: () => Promise<{ + iTwins: ITwinFull[]; + totalCount: number | undefined; + hasMore: boolean; + }>; +} => { + const { requestType, filterText, iTwinSubClass, orderby } = query; + const clientSideFiltered = isClientSideFiltered(requestType); + const endpoint = clientSideFiltered ? requestType : ""; + const subClass = `?subClass=${iTwinSubClass === "All" ? "" : iTwinSubClass}`; + const paging = `&$skip=${page * PAGE_SIZE}&$top=${PAGE_SIZE}`; + const search = + clientSideFiltered || !filterText + ? "" + : `&$search=${encodeURIComponent(filterText.trim())}`; + const ordering = + clientSideFiltered || !orderby + ? "" + : `&$orderby=${encodeURIComponent(orderby.trim())}`; + + const abortController = new AbortController(); + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/itwins/${endpoint}${subClass}${paging}${search}${ordering}`; + + const doFetchRequest = async () => { + const options: RequestInit = { + signal: abortController.signal, + headers: { + "Cache-Control": + requestType === "favorites" && shouldRefetchFavorites + ? "no-cache" + : "", + Authorization: + typeof accessToken === "function" ? await accessToken() : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v1+json", + Prefer: "return=representation", + "x-total-count": "true", + }, + }; + + const response = await fetch(url, options); + const result: { iTwins: ITwinFull[] } = response.ok + ? await response.json() + : await response.text().then((errorText) => { + throw new Error(errorText); + }); + + const totalCountHeader = response.headers.get("x-total-count"); + return { + iTwins: result.iTwins, + totalCount: + totalCountHeader !== null ? Number(totalCountHeader) : undefined, + hasMore: result.iTwins.length === PAGE_SIZE, + }; + }; + + return { abortController, fetchITwins: doFetchRequest }; +}; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts new file mode 100644 index 00000000..f7eebae7 --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + DataStatus, + ITwinDataQuery, + ITwinDataState, + ITwinFull, +} from "../../types"; +import { useITwinFilter } from "./useITwinFilter"; + +/** One object, so a render can never pair one query's status with another query's iTwins. */ +interface FetchState extends Omit { + /** Every page fetched, where a report carries only what client side filtering kept. */ + iTwins: ITwinFull[]; +} + +const startingOver = (query: ITwinDataQuery): FetchState => ({ + query, + status: DataStatus.Fetching, + iTwins: [], + hasMore: true, + error: undefined, +}); + +const sameQuery = (a: ITwinDataQuery, b: ITwinDataQuery) => + a.requestType === b.requestType && + a.filterText === b.filterText && + a.iTwinSubClass === b.iTwinSubClass && + a.orderby === b.orderby; + +const hasStartedOver = (state: FetchState, query: ITwinDataQuery) => + sameQuery(state.query, query) && + state.status === DataStatus.Fetching && + state.iTwins.length === 0 && + state.hasMore; + +export const useITwinDataState = ( + query: ITwinDataQuery, + onDataStateChange?: (state: ITwinDataState) => void +) => { + const [fetchState, setFetchState] = React.useState(() => + startingOver(query) + ); + const iTwins = useITwinFilter(fetchState.iTwins, query.filterText); + + const queryRef = React.useRef(query); + const onDataStateChangeRef = React.useRef(onDataStateChange); + React.useEffect(() => { + queryRef.current = query; + onDataStateChangeRef.current = onDataStateChange; + }); + + const dataState = React.useMemo( + () => ({ ...fetchState, iTwins }), + [fetchState, iTwins] + ); + React.useEffect(() => { + // The new query has not reached the state yet, so reporting now would pair it with the + // previous query's result. + if (sameQuery(dataState.query, query)) { + onDataStateChangeRef.current?.(dataState); + } + }, [dataState, query]); + + /** Start over for the query in hand. Reads it from the ref so the caller can reset from an + * effect that must not depend on the query. */ + const reset = React.useCallback(() => { + setFetchState((state) => + hasStartedOver(state, queryRef.current) + ? state + : startingOver(queryRef.current) + ); + }, []); + + /** The iTwins in hand already answer the new query, so keep them and just retarget. */ + const applyQuery = React.useCallback((query: ITwinDataQuery) => { + setFetchState((state) => + sameQuery(state.query, query) ? state : { ...state, query } + ); + }, []); + + const markFetching = React.useCallback(() => { + setFetchState((state) => + state.status === DataStatus.Fetching + ? state + : { ...state, status: DataStatus.Fetching, error: undefined } + ); + }, []); + + const pageLoaded = React.useCallback( + (page: { iTwins: ITwinFull[]; isFirstPage: boolean; hasMore: boolean }) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins: page.isFirstPage + ? page.iTwins + : [...state.iTwins, ...page.iTwins], + hasMore: page.hasMore, + error: undefined, + })); + }, + [] + ); + + const pageFailed = React.useCallback( + (failure: { error: unknown; isFirstPage: boolean }) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.FetchFailed, + iTwins: failure.isFirstPage ? [] : state.iTwins, + error: failure.error, + })); + }, + [] + ); + + /** Leaves hasMore alone: flipping it here would re-run the caller's fetch effect into this + * same branch. */ + const tokenRequired = React.useCallback(() => { + setFetchState((state) => ({ + ...state, + status: DataStatus.TokenRequired, + iTwins: [], + error: undefined, + })); + }, []); + + const dataProvided = React.useCallback((iTwins: ITwinFull[]) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins, + hasMore: false, + error: undefined, + })); + }, []); + + return { + status: fetchState.status, + iTwins, + hasMore: fetchState.hasMore, + reset, + applyQuery, + markFetching, + pageLoaded, + pageFailed, + tokenRequired, + dataProvided, + }; +}; diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx new file mode 100644 index 00000000..a22ece2c --- /dev/null +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import "@testing-library/jest-dom"; + +import { Root } from "@stratakit/mui"; +import { render } from "@testing-library/react"; +import React from "react"; + +import * as useITwinData from "../../../containers/ITwinGrid/useITwinData"; +import { DataStatus } from "../../../types"; +import { ITwinGridMUI } from "./ITwinGridMUI"; + +// The real DataGrid needs a TextEncoder that jsdom does not provide. +jest.mock("@mui/x-data-grid", () => ({ + DataGrid: () =>
, +})); + +describe("ITwinGridMUI", () => { + beforeEach(() => { + jest.spyOn(useITwinData, "useITwinData").mockReturnValue({ + iTwins: [], + status: DataStatus.Complete, + totalCount: 0, + fetchMore: undefined, + refetchITwins: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should hand onDataStateChange to the data hook", () => { + const onDataStateChange = jest.fn(); + + render( + + + + ); + + expect(useITwinData.useITwinData).toHaveBeenCalledWith( + expect.objectContaining({ onDataStateChange }) + ); + }); +}); diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index 5603e9b5..351c5fc0 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -109,6 +109,7 @@ const ITwinGridMUIInternal = ({ tileOverrides, useIndividualState, postProcessCallback, + onDataStateChange, viewMode, tableOverrides, className, @@ -177,6 +178,7 @@ const ITwinGridMUIInternal = ({ orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, + onDataStateChange, }); const iTwins = React.useMemo( diff --git a/packages/modules/imodel-browser/src/mui/index.ts b/packages/modules/imodel-browser/src/mui/index.ts index 455d4749..7fce45d3 100644 --- a/packages/modules/imodel-browser/src/mui/index.ts +++ b/packages/modules/imodel-browser/src/mui/index.ts @@ -59,6 +59,8 @@ export type { IModelFull, ITwinFull, ApiOverrides, + ITwinDataQuery, + ITwinDataState, ITwinFilterOptions, DataMode, IModelSortOptionsKeys, diff --git a/packages/modules/imodel-browser/src/types.ts b/packages/modules/imodel-browser/src/types.ts index dc946a06..0f656d5e 100644 --- a/packages/modules/imodel-browser/src/types.ts +++ b/packages/modules/imodel-browser/src/types.ts @@ -87,6 +87,21 @@ export enum DataStatus { ContextRequired = "error_contextRequired", } +export interface ITwinDataQuery { + requestType: "favorites" | "recents" | ""; + filterText: string; + iTwinSubClass: ITwinSubClass; + orderby?: string; +} + +export interface ITwinDataState { + query: ITwinDataQuery; + status: DataStatus; + iTwins: ITwinFull[]; + hasMore: boolean; + error?: unknown; +} + /** * Specifies the data management mode for the hook. * - "internal": Hook manages all data fetching, pagination, sorting, and filtering. Requires accessToken and iTwinId.