Skip to content

Commit e2e1bd5

Browse files
committed
feat: Use Store API pagination for remodels table
1 parent 32ef03d commit e2e1bd5

8 files changed

Lines changed: 409 additions & 57 deletions

File tree

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ canonicalwebteam.discourse==7.2.0
66
canonicalwebteam.blog==6.8.4
77
canonicalwebteam.search==2.1.2
88
canonicalwebteam.image-template==1.9.0
9-
canonicalwebteam.store-api==7.8.1
9+
canonicalwebteam.store-api==7.8.2
1010
canonicalwebteam.launchpad==0.9.0
1111
django-openid-auth==0.17
1212
Flask-OpenID==1.3.1

static/js/publisher/hooks/__tests__/useRemodels.test.tsx

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,38 @@ afterAll(() => {
7272

7373
describe("useRemodels", () => {
7474
test("returns remodels data", async () => {
75+
const { result } = renderHook(() => useRemodels("test-brand-id"), {
76+
wrapper: createWrapper(),
77+
});
78+
79+
await waitFor(() => {
80+
expect(result.current.isSuccess).toBe(true);
81+
});
82+
83+
expect(result.current.data).toEqual({
84+
data: remodelsResponse,
85+
success: true,
86+
});
87+
});
88+
89+
test("returns remodels data with pageSize param", async () => {
90+
server.use(
91+
http.get(
92+
"/api/store/test-brand-id/models/remodel-allowlist",
93+
({ request }) => {
94+
const url = new URL(request.url);
95+
96+
expect(url.searchParams.get("page-size")).toBe("10");
97+
return HttpResponse.json({
98+
data: remodelsResponse,
99+
success: true,
100+
});
101+
},
102+
),
103+
);
104+
75105
const { result } = renderHook(
76-
() => useRemodels("test-brand-id", "test-to-model"),
106+
() => useRemodels("test-brand-id", { pageSize: 10 }),
77107
{
78108
wrapper: createWrapper(),
79109
},
@@ -89,9 +119,27 @@ describe("useRemodels", () => {
89119
});
90120
});
91121

92-
test("returns error if request fails", async () => {
122+
test("returns remodels data with page param", async () => {
123+
server.use(
124+
http.get(
125+
"/api/store/test-brand-id/models/remodel-allowlist",
126+
({ request }) => {
127+
const url = new URL(request.url);
128+
129+
expect(url.searchParams.get("page")).toBe("next_cursor_value");
130+
return HttpResponse.json({
131+
data: remodelsResponse,
132+
success: true,
133+
});
134+
},
135+
),
136+
);
137+
93138
const { result } = renderHook(
94-
() => useRemodels("test-brand-id-fail", "test-to-model"),
139+
() =>
140+
useRemodels("test-brand-id", {
141+
page: "next_cursor_value",
142+
}),
95143
{
96144
wrapper: createWrapper(),
97145
},
@@ -102,19 +150,107 @@ describe("useRemodels", () => {
102150
});
103151

104152
expect(result.current.data).toEqual({
105-
message: "There was a problem fetching remodels",
106-
success: false,
153+
data: remodelsResponse,
154+
success: true,
107155
});
108156
});
109157

110-
test("returns error if network error", async () => {
158+
test("returns remodels data with fromModel param", async () => {
159+
server.use(
160+
http.get(
161+
"/api/store/test-brand-id/models/remodel-allowlist",
162+
({ request }) => {
163+
const url = new URL(request.url);
164+
165+
expect(url.searchParams.get("from-model")).toBe("test-from-model");
166+
return HttpResponse.json({
167+
data: remodelsResponse,
168+
success: true,
169+
});
170+
},
171+
),
172+
);
173+
174+
const { result } = renderHook(
175+
() =>
176+
useRemodels("test-brand-id", {
177+
fromModel: "test-from-model",
178+
}),
179+
{
180+
wrapper: createWrapper(),
181+
},
182+
);
183+
184+
await waitFor(() => {
185+
expect(result.current.isSuccess).toBe(true);
186+
});
187+
188+
expect(result.current.data).toEqual({
189+
data: remodelsResponse,
190+
success: true,
191+
});
192+
});
193+
194+
test("returns remodels data with pageSize, page and fromModel param", async () => {
195+
server.use(
196+
http.get(
197+
"/api/store/test-brand-id/models/remodel-allowlist",
198+
({ request }) => {
199+
const url = new URL(request.url);
200+
201+
expect(url.searchParams.get("page-size")).toBe("25");
202+
expect(url.searchParams.get("page")).toBe("cursor123");
203+
expect(url.searchParams.get("from-model")).toBe("test-from-model");
204+
return HttpResponse.json({
205+
data: remodelsResponse,
206+
success: true,
207+
});
208+
},
209+
),
210+
);
211+
111212
const { result } = renderHook(
112-
() => useRemodels("test-brand-id-error", "test-to-model"),
213+
() =>
214+
useRemodels("test-brand-id", {
215+
pageSize: 25,
216+
page: "cursor123",
217+
fromModel: "test-from-model",
218+
}),
113219
{
114220
wrapper: createWrapper(),
115221
},
116222
);
117223

224+
await waitFor(() => {
225+
expect(result.current.isSuccess).toBe(true);
226+
});
227+
228+
expect(result.current.data).toEqual({
229+
data: remodelsResponse,
230+
success: true,
231+
});
232+
});
233+
234+
test("returns error if request fails", async () => {
235+
const { result } = renderHook(() => useRemodels("test-brand-id-fail"), {
236+
wrapper: createWrapper(),
237+
});
238+
239+
await waitFor(() => {
240+
expect(result.current.isSuccess).toBe(true);
241+
});
242+
243+
expect(result.current.data).toEqual({
244+
message: "There was a problem fetching remodels",
245+
success: false,
246+
});
247+
});
248+
249+
test("returns error if network error", async () => {
250+
const { result } = renderHook(() => useRemodels("test-brand-id-error"), {
251+
wrapper: createWrapper(),
252+
});
253+
118254
await waitFor(() => {
119255
expect(result.current.isError).toBe(true);
120256
});

static/js/publisher/hooks/useRemodels.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,46 @@
11
import { useQuery, UseQueryResult } from "react-query";
2-
import type { Remodel, RemodelResponse, ApiResponse } from "../types/shared";
2+
import type { RemodelResponse, ApiResponse } from "../types/shared";
33

44
const useRemodels = (
55
brandId: string | undefined,
6-
modelId: string | undefined,
6+
urlSearchParams?: {
7+
page?: string | null;
8+
pageSize?: number;
9+
fromModel?: string;
10+
},
711
): UseQueryResult<ApiResponse<RemodelResponse>, Error> => {
8-
return useQuery<ApiResponse<RemodelResponse>, Error>({
9-
queryKey: ["remodels", brandId, modelId],
10-
queryFn: async () => {
11-
const response = await fetch(
12-
`/api/store/${brandId}/models/remodel-allowlist`,
13-
);
14-
15-
const responseData = await response.json();
12+
const url = new URL(
13+
`/api/store/${brandId}/models/remodel-allowlist`,
14+
window.location.origin,
15+
);
1616

17-
if (responseData.data?.allowlist) {
18-
const remodelsForCurrentModel = responseData.data.allowlist.filter(
19-
(remodel: Remodel) => {
20-
return (
21-
remodel["from-model"] === modelId ||
22-
remodel["to-model"] === modelId
23-
);
24-
},
25-
);
17+
if (urlSearchParams) {
18+
const { page, pageSize, fromModel } = urlSearchParams;
2619

27-
responseData.data.allowlist = remodelsForCurrentModel.sort(
28-
(a: Remodel, b: Remodel) => {
29-
if (a["created-at"] > b["created-at"]) {
30-
return -1;
31-
}
20+
if (page) {
21+
url.searchParams.set("page", page);
22+
}
3223

33-
if (a["created-at"] < b["created-at"]) {
34-
return 1;
35-
}
24+
if (pageSize) {
25+
url.searchParams.set("page-size", pageSize.toString());
26+
}
3627

37-
return 0;
38-
},
39-
);
40-
}
28+
if (fromModel) {
29+
url.searchParams.set("from-model", fromModel);
30+
}
31+
}
4132

33+
return useQuery<ApiResponse<RemodelResponse>, Error>({
34+
queryKey: [
35+
"remodels",
36+
brandId,
37+
urlSearchParams?.page,
38+
urlSearchParams?.pageSize,
39+
urlSearchParams?.fromModel,
40+
],
41+
queryFn: async () => {
42+
const response = await fetch(url);
43+
const responseData = await response.json();
4244
return responseData;
4345
},
4446
enabled: !!brandId,

static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { brandIdState } from "../../state/brandStoreState";
77
function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
88
const { id, modelId } = useParams();
99
const brandId = useAtomValue(brandIdState);
10-
const { data: remodelsData } = useRemodels(brandId, modelId);
10+
const { data: remodelsData } = useRemodels(brandId);
1111

1212
return (
1313
<nav className="p-tabs">

static/js/publisher/pages/Remodel/Remodel.tsx

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useState, useRef } from "react";
22
import { useAtomValue, useSetAtom } from "jotai";
3-
import { useParams, Link, useNavigate, useLocation } from "react-router-dom";
3+
import {
4+
useParams,
5+
Link,
6+
useNavigate,
7+
useLocation,
8+
useSearchParams,
9+
} from "react-router-dom";
410
import { Notification, Icon, Row, Col } from "@canonical/react-components";
511

612
import { useRemodels } from "../../hooks";
@@ -17,8 +23,17 @@ import type { Remodel, RemodelResponse, ApiResponse } from "../../types/shared";
1723

1824
function Remodel(): React.JSX.Element {
1925
const { id, modelId } = useParams();
26+
const [searchParams, setSearchParams] = useSearchParams();
2027
const location = useLocation();
2128
const brandId = useAtomValue(brandIdState);
29+
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
30+
const [nextCursor, setNextCursor] = useState<string | null>(null);
31+
const cursorHistory = useRef<Array<string | null>>([]);
32+
33+
const pageSizeParam = searchParams.get("page-size");
34+
const parsedPageSize = pageSizeParam ? parseInt(pageSizeParam) : NaN;
35+
const pageSize = Number.isInteger(parsedPageSize) ? parsedPageSize : 25;
36+
2237
const {
2338
isLoading,
2439
isError,
@@ -27,7 +42,11 @@ function Remodel(): React.JSX.Element {
2742
refetch,
2843
}: UseQueryResult<ApiResponse<RemodelResponse>, Error> = useRemodels(
2944
brandId,
30-
modelId,
45+
{
46+
fromModel: modelId,
47+
pageSize: pageSize,
48+
page: currentCursor,
49+
},
3150
);
3251
const setRemodels = useSetAtom(remodelsListState);
3352
const [showNotification, setShowNotification] = useState(false);
@@ -36,13 +55,36 @@ function Remodel(): React.JSX.Element {
3655
const brandStore = useAtomValue(brandStoreState(id));
3756
const navigate = useNavigate();
3857

58+
const handlePageForward = () => {
59+
cursorHistory.current.push(currentCursor);
60+
setCurrentCursor(nextCursor);
61+
};
62+
63+
const handlePageBack = () => {
64+
const lastCursor = cursorHistory.current.pop();
65+
setCurrentCursor(lastCursor || null);
66+
};
67+
68+
const handlePageSizeChange = (newPageSize: number) => {
69+
// Need to reset current page when changing page size
70+
// because otherwise the cursor history gets out of sync
71+
setCurrentCursor(null);
72+
cursorHistory.current = [];
73+
setSearchParams({ "page-size": newPageSize.toString() });
74+
};
75+
3976
brandStore
4077
? setPageTitle(`Remodels in ${brandStore.name}`)
4178
: setPageTitle("Remodels");
4279

4380
useEffect(() => {
44-
if (!isLoading && !isError && data) {
81+
if (isLoading || isError) {
82+
return;
83+
}
84+
85+
if (data) {
4586
setRemodels(data.data?.allowlist || []);
87+
setNextCursor(data.data?.["next-cursor"] || null);
4688
}
4789
}, [isLoading, isError, data, brandId, id]);
4890

@@ -76,7 +118,18 @@ function Remodel(): React.JSX.Element {
76118
</Col>
77119
</Row>
78120
<div className="u-flex-column u-flex-grow">
79-
<RemodelTable />
121+
{data && (
122+
<RemodelTable
123+
handlePageForward={handlePageForward}
124+
handlePageBack={handlePageBack}
125+
handlePageSizeChange={handlePageSizeChange}
126+
forwardDisabled={!nextCursor}
127+
backDisabled={
128+
cursorHistory.current.length < 1 || currentCursor === null
129+
}
130+
pageSize={pageSize}
131+
/>
132+
)}
80133
</div>
81134
</>
82135
)}

0 commit comments

Comments
 (0)