Skip to content

Commit fb85926

Browse files
Updated the IModel Grid component to provide support for infinite scrolling while providing the data from Consumer Side (#188)
1 parent 165cba6 commit fb85926

6 files changed

Lines changed: 238 additions & 99 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@itwin/imodel-browser-react",
5+
"comment": "Added callback to fetch the data while providing the data from consumer's end",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@itwin/imodel-browser-react"
10+
}

packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx

Lines changed: 119 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -81,28 +81,125 @@ OverrideCellData.args = {
8181
},
8282
};
8383

84-
export const OverrideApiData = Template.bind({});
85-
OverrideApiData.args = {
86-
apiOverrides: {
87-
data: [
88-
{
89-
id: "1",
90-
displayName: "Provided iModel",
91-
description: "No Network Calls",
92-
thumbnail:
93-
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/activity.svg",
94-
},
95-
{
96-
id: "2",
97-
displayName: "Useful iModel",
98-
description:
99-
"Use if the data comes from a different API or needs to be tweaked",
100-
thumbnail:
101-
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/developer.svg",
102-
},
103-
],
104-
},
105-
};
84+
export const OverrideApiDataWithLoadMore: Story<IModelGridProps> =
85+
withITwinIdOverride(
86+
withAccessTokenOverride((args) => {
87+
const initialData: IModelFull[] = [
88+
{
89+
id: "1",
90+
displayName: "External iModel 1",
91+
description: "Loaded from external source",
92+
thumbnail:
93+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/activity.svg",
94+
},
95+
{
96+
id: "2",
97+
displayName: "External iModel 2",
98+
description: "Consumer manages pagination",
99+
thumbnail:
100+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/developer.svg",
101+
},
102+
{
103+
id: "3",
104+
displayName: "External iModel 3",
105+
description: "Pagination demo",
106+
thumbnail:
107+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/folder.svg",
108+
},
109+
{
110+
id: "4",
111+
displayName: "External iModel 4",
112+
description: "Initial batch of 6",
113+
thumbnail:
114+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/organization.svg",
115+
},
116+
{
117+
id: "5",
118+
displayName: "External iModel 5",
119+
description: "More data",
120+
thumbnail:
121+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/settings.svg",
122+
},
123+
{
124+
id: "6",
125+
displayName: "External iModel 6",
126+
description: "Last in first batch",
127+
thumbnail:
128+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/tools.svg",
129+
},
130+
];
131+
132+
const [data, setData] = React.useState<IModelFull[]>(initialData);
133+
const [isLoading, setIsLoading] = React.useState(false);
134+
const [hasMore, setHasMore] = React.useState(true);
135+
136+
const handleLoadMore = React.useCallback(async () => {
137+
setIsLoading(true);
138+
// Simulate network delay
139+
await new Promise((resolve) => setTimeout(resolve, 2000));
140+
setData((prev) => [
141+
...prev,
142+
{
143+
id: "7",
144+
displayName: "External iModel 7",
145+
description: "Loaded on demand via onLoadMore",
146+
thumbnail:
147+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/folder.svg",
148+
},
149+
{
150+
id: "8",
151+
displayName: "External iModel 8",
152+
description: "Second batch",
153+
thumbnail:
154+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/organization.svg",
155+
},
156+
{
157+
id: "9",
158+
displayName: "External iModel 9",
159+
description: "More paginated data",
160+
thumbnail:
161+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/settings.svg",
162+
},
163+
{
164+
id: "10",
165+
displayName: "External iModel 10",
166+
description: "Second batch item",
167+
thumbnail:
168+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/tools.svg",
169+
},
170+
{
171+
id: "11",
172+
displayName: "External iModel 11",
173+
description: "Second batch item",
174+
thumbnail:
175+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/activity.svg",
176+
},
177+
{
178+
id: "12",
179+
displayName: "External iModel 12",
180+
description: "Last in second batch",
181+
thumbnail:
182+
"https://unpkg.com/@bentley/icons-generic@1.0.34/icons/developer.svg",
183+
},
184+
]);
185+
setHasMore(false);
186+
setIsLoading(false);
187+
}, []);
188+
189+
return (
190+
<IModelGrid
191+
{...args}
192+
dataMode="external"
193+
apiOverrides={{
194+
data,
195+
isLoading,
196+
hasMoreData: hasMore,
197+
}}
198+
onLoadMore={handleLoadMore}
199+
/>
200+
);
201+
})
202+
);
106203

107204
export const IndividualContextMenu = Template.bind({});
108205
IndividualContextMenu.args = {

packages/modules/imodel-browser/src/containers/iModelGrid/IModelGrid.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { IModelFavoritesProvider } from "../../contexts/IModelFavoritesContext";
1212
import {
1313
AccessTokenProvider,
1414
ApiOverrides,
15+
DataMode,
1516
DataStatus,
1617
IModelCellOverrides,
1718
IModelFull,
@@ -82,6 +83,8 @@ export interface IModelGridProps {
8283
};
8384
/** Object that configures different overrides for the API.
8485
* @property `data`: Array of iModels used in the grid.
86+
* @property `isLoading`: Loading state when using consumer-provided data.
87+
* @property `hasMoreData`: Whether more data is available for infinite scroll (external mode only).
8588
* @property `serverEnvironmentPrefix`: Either qa or dev.
8689
*/
8790
apiOverrides?: ApiOverrides<IModelFull[]>;
@@ -108,6 +111,24 @@ export interface IModelGridProps {
108111
cellOverrides?: IModelCellOverrides;
109112
/** Additional class name for the grid structure */
110113
className?: string;
114+
/**
115+
* Specifies how data should be managed.
116+
* - 'internal': Package handles data fetching internally (default)
117+
* - 'external': Consumer manages data via apiOverrides.data and isLoading.
118+
* When using 'external' mode, `accessToken` and `iTwinId` are not required, as the consumer is responsible for data fetching.
119+
* Allows for infinite scrolling and data refresh via onLoadMore and onRefetch callbacks.
120+
*/
121+
dataMode?: DataMode;
122+
/**
123+
* Callback function to load more data when using external data mode.
124+
* Only used when dataMode is set to 'external'. This enables infinite scrolling when you provide data directly from your consumer.
125+
*/
126+
onLoadMore?: () => void | Promise<void>;
127+
/**
128+
* Callback function to refresh data when using external data mode.
129+
* Only used when dataMode is set to 'external'.
130+
*/
131+
onRefetch?: () => void | Promise<void>;
111132
}
112133

113134
/**
@@ -145,6 +166,9 @@ const ITwinGridInternal = ({
145166
maxCount,
146167
cellOverrides,
147168
className,
169+
onLoadMore,
170+
onRefetch,
171+
dataMode = "internal",
148172
disableAddToRecents = false,
149173
}: IModelGridProps) => {
150174
const [sort, setSort] = React.useState<IModelSortOptions>(sortOptions);
@@ -241,6 +265,9 @@ const ITwinGridInternal = ({
241265
maxCount,
242266
pageSize,
243267
viewMode,
268+
dataMode,
269+
onLoadMore,
270+
onRefetch,
244271
});
245272

246273
const iModels = React.useMemo(

packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.test.ts

Lines changed: 23 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { rest } from "msw";
77
import { act } from "react";
88

99
import { server } from "../../tests/mocks/server";
10-
import { DataStatus, IModelSortOptionsKeys } from "../../types";
10+
import { DataStatus } from "../../types";
1111
import { useIModelData } from "./useIModelData";
1212

1313
describe("useIModelData hook", () => {
@@ -69,7 +69,11 @@ describe("useIModelData hook", () => {
6969
});
7070

7171
it("returns apiOverrides.data without fetching when it is provided", async () => {
72-
const data = [{ id: "rerenderedId", displayName: "rerenderedDisplayName" }];
72+
const externalData = [
73+
{ id: "externalId1", displayName: "External IModel 1" },
74+
{ id: "externalId2", displayName: "External IModel 2" },
75+
];
76+
const onLoadMore = jest.fn();
7377
const watcher = jest.fn();
7478
server.use(
7579
rest.get("https://api.bentley.com/imodels/", (req, res, ctx) => {
@@ -94,19 +98,31 @@ describe("useIModelData hook", () => {
9498

9599
rerender([
96100
{
101+
dataMode: "external",
97102
apiOverrides: {
98-
data,
103+
data: externalData,
104+
isLoading: false,
105+
hasMoreData: true,
99106
},
107+
onLoadMore,
100108
},
101109
]);
102110

103-
expect(result.current.iModels).toEqual([
104-
{ id: "rerenderedId", displayName: "rerenderedDisplayName" },
105-
]);
111+
expect(result.current.iModels).toEqual(externalData);
106112
expect(result.current.status).toEqual(DataStatus.Complete);
113+
expect(result.current.fetchMore).toBe(onLoadMore);
107114
expect(watcher).toHaveBeenCalledTimes(1);
108115

109-
rerender([{ iTwinId: "iTwinId", accessToken: "accessToken" }]);
116+
act(() => result.current.fetchMore?.());
117+
expect(onLoadMore).toHaveBeenCalledTimes(1);
118+
119+
rerender([
120+
{
121+
iTwinId: "iTwinId",
122+
accessToken: "accessToken",
123+
},
124+
]);
125+
110126
await waitForNextUpdate();
111127

112128
expect(result.current.iModels).toEqual([]);
@@ -131,65 +147,6 @@ describe("useIModelData hook", () => {
131147
expect(result.current.status).toEqual(DataStatus.ContextRequired);
132148
});
133149

134-
it("apply sorting", async () => {
135-
const expectedSortOrder = ["2", "4", "5", "1", "3"];
136-
const options = {
137-
apiOverrides: {
138-
data: [
139-
{
140-
id: "1",
141-
displayName: "d",
142-
name: "c",
143-
description: "e",
144-
initialized: true,
145-
createdDateTime: "2020-09-05T12:42:51.593Z",
146-
},
147-
{
148-
id: "2",
149-
displayName: "a",
150-
name: "d",
151-
description: "d",
152-
initialized: true,
153-
createdDateTime: "2020-09-03T12:42:51.593Z",
154-
},
155-
{
156-
id: "3",
157-
displayName: "e",
158-
name: "a",
159-
description: "c",
160-
initialized: false,
161-
createdDateTime: "2020-09-04T12:42:51.593Z",
162-
},
163-
{
164-
id: "4",
165-
displayName: "b",
166-
name: "b",
167-
description: "b",
168-
initialized: false,
169-
createdDateTime: "2020-09-01T12:42:51.593Z",
170-
},
171-
{
172-
id: "5",
173-
displayName: "c",
174-
name: "d",
175-
description: "a",
176-
initialized: true,
177-
createdDateTime: "2020-09-02T12:42:51.593Z",
178-
},
179-
],
180-
},
181-
sortOptions: {
182-
sortType: "displayName" as IModelSortOptionsKeys,
183-
descending: false,
184-
},
185-
};
186-
const { result } = renderHook(() => useIModelData(options));
187-
188-
expect(result.current.iModels.map((iModel) => iModel.id)).toEqual(
189-
expectedSortOrder
190-
);
191-
});
192-
193150
it("should call correct api when maxCount is provided", async () => {
194151
// Arrange
195152
const fetchSpy = jest.spyOn(window, "fetch");

0 commit comments

Comments
 (0)