Skip to content

Commit 5e3ca17

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

8 files changed

Lines changed: 429 additions & 56 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: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { useEffect, useState } 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,24 @@ 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, setCursorHistory] = useState<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+
37+
useEffect(() => {
38+
// Need to reset current page when changing page size
39+
// because otherwise the cursor history gets out of sync
40+
setCurrentCursor(null);
41+
setCursorHistory([]);
42+
}, [pageSize]);
43+
2244
const {
2345
isLoading,
2446
isError,
@@ -27,7 +49,11 @@ function Remodel(): React.JSX.Element {
2749
refetch,
2850
}: UseQueryResult<ApiResponse<RemodelResponse>, Error> = useRemodels(
2951
brandId,
30-
modelId,
52+
{
53+
fromModel: modelId,
54+
pageSize: pageSize,
55+
page: currentCursor,
56+
},
3157
);
3258
const setRemodels = useSetAtom(remodelsListState);
3359
const [showNotification, setShowNotification] = useState(false);
@@ -36,13 +62,33 @@ function Remodel(): React.JSX.Element {
3662
const brandStore = useAtomValue(brandStoreState(id));
3763
const navigate = useNavigate();
3864

65+
const handlePageForward = () => {
66+
setCursorHistory((prev) => {
67+
return [...prev, currentCursor];
68+
});
69+
setCurrentCursor(nextCursor);
70+
};
71+
72+
const handlePageBack = () => {
73+
const lastCursor = cursorHistory[cursorHistory.length - 1] ?? null;
74+
setCurrentCursor(lastCursor);
75+
setCursorHistory((prev) => {
76+
return prev.slice(0, -1);
77+
});
78+
};
79+
3980
brandStore
4081
? setPageTitle(`Remodels in ${brandStore.name}`)
4182
: setPageTitle("Remodels");
4283

4384
useEffect(() => {
44-
if (!isLoading && !isError && data) {
85+
if (isLoading || isError) {
86+
return;
87+
}
88+
89+
if (data) {
4590
setRemodels(data.data?.allowlist || []);
91+
setNextCursor(data.data?.["next-cursor"] || null);
4692
}
4793
}, [isLoading, isError, data, brandId, id]);
4894

@@ -76,7 +122,18 @@ function Remodel(): React.JSX.Element {
76122
</Col>
77123
</Row>
78124
<div className="u-flex-column u-flex-grow">
79-
<RemodelTable />
125+
{data && (
126+
<RemodelTable
127+
handlePageForward={handlePageForward}
128+
handlePageBack={handlePageBack}
129+
forwardDisabled={!nextCursor}
130+
backDisabled={
131+
cursorHistory.length < 1 || currentCursor === null
132+
}
133+
pageSize={pageSize}
134+
setSearchParams={setSearchParams}
135+
/>
136+
)}
80137
</div>
81138
</>
82139
)}

0 commit comments

Comments
 (0)