Skip to content

Commit 9080706

Browse files
committed
feat: Add remodels table
1 parent 826273d commit 9080706

14 files changed

Lines changed: 378 additions & 8 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { QueryClient, QueryClientProvider } from "react-query";
2+
import { renderHook, waitFor } from "@testing-library/react";
3+
import { http, HttpResponse } from "msw";
4+
import { setupServer } from "msw/node";
5+
6+
import useRemodels from "../useRemodels";
7+
8+
import type { ReactNode } from "react";
9+
10+
const queryClient = new QueryClient();
11+
12+
const createWrapper = () => {
13+
return ({ children }: { children: ReactNode }) => (
14+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
15+
);
16+
};
17+
18+
const remodelsResponse = {
19+
allowlist: [
20+
{
21+
"created-at": "2023-09-05T11:55:53.732366",
22+
"created-by": "John Doe",
23+
description: "Test description",
24+
"from-model": "test-from-model",
25+
"from-serial": "test-from-serial",
26+
"modified-at": null,
27+
"modified-by": null,
28+
"to-model": "test-to-model",
29+
},
30+
],
31+
};
32+
33+
const handlers = [
34+
http.get("/api/store/test-brand-id/models/remodel-allowlist", () => {
35+
return HttpResponse.json({
36+
data: remodelsResponse,
37+
message: "",
38+
success: true,
39+
});
40+
}),
41+
http.get("/api/store/test-brand-id-fail/models/remodel-allowlist", () => {
42+
return HttpResponse.json({
43+
data: [],
44+
message: "There was a problem fetching remodels",
45+
success: false,
46+
});
47+
}),
48+
http.get("/api/store/test-brand-id-error/models/remodel-allowlist", () => {
49+
return HttpResponse.error();
50+
}),
51+
];
52+
53+
const server = setupServer(...handlers);
54+
55+
beforeAll(() => {
56+
server.listen();
57+
});
58+
59+
afterEach(() => {
60+
server.resetHandlers();
61+
queryClient.clear();
62+
});
63+
64+
afterAll(() => {
65+
server.close();
66+
});
67+
68+
describe("useRemodels", () => {
69+
test("returns remodels data", async () => {
70+
const { result } = renderHook(() => useRemodels("test-brand-id"), {
71+
wrapper: createWrapper(),
72+
});
73+
74+
await waitFor(() => result.current.isSuccess);
75+
76+
await waitFor(() => {
77+
expect(result.current.data).toEqual(remodelsResponse.allowlist);
78+
});
79+
});
80+
81+
test("returns no data if request fails", async () => {
82+
const { result } = renderHook(() => useRemodels("test-brand-id-fail"), {
83+
wrapper: createWrapper(),
84+
});
85+
86+
await waitFor(() => result.current.isSuccess);
87+
88+
await waitFor(() => {
89+
expect(result.current.data).toBeUndefined();
90+
});
91+
});
92+
93+
test("returns no data if error", async () => {
94+
const { result } = renderHook(() => useRemodels("test-brand-id-error"), {
95+
wrapper: createWrapper(),
96+
});
97+
98+
await waitFor(() => result.current.isError);
99+
100+
await waitFor(() => {
101+
expect(result.current.data).toBeUndefined();
102+
});
103+
});
104+
});

static/js/publisher/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import useFetchAccountSnaps from "./useFetchAccountSnaps";
1515
import useFetchPublishedSnapMetrics from "./useFetchPublishedSnapMetrics";
1616
import useSortTableData from "./useSortTableData";
1717
import useAccountKeys from "./useAccountKeys";
18+
import useRemodels from "./useRemodels";
1819

1920
export {
2021
useValidationSets,
@@ -34,4 +35,5 @@ export {
3435
useFetchPublishedSnapMetrics,
3536
useSortTableData,
3637
useAccountKeys,
38+
useRemodels,
3739
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useQuery, UseQueryResult } from "react-query";
2+
import type { Remodel as RemodelType } from "../types/shared";
3+
4+
const useRemodels = (
5+
brandId: string | undefined,
6+
): UseQueryResult<RemodelType[], Error> => {
7+
return useQuery<RemodelType[], Error>({
8+
queryKey: ["remodels", brandId],
9+
queryFn: async () => {
10+
const response = await fetch(
11+
`/api/store/${brandId}/models/remodel-allowlist`,
12+
);
13+
14+
if (!response.ok) {
15+
throw new Error("There was a problem fetching remodels");
16+
}
17+
18+
const remodelsData = await response.json();
19+
20+
if (!remodelsData.success) {
21+
throw new Error(remodelsData.message);
22+
}
23+
24+
return remodelsData.data.allowlist;
25+
},
26+
enabled: !!brandId,
27+
});
28+
};
29+
30+
export default useRemodels;

static/js/publisher/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const Snaps = importComponent(() => import("./pages/Snaps"));
4040
const ValidationSet = importComponent(() => import("./pages/ValidationSet"));
4141
const ValidationSets = importComponent(() => import("./pages/ValidationSets"));
4242
const AccountKeys = importComponent(() => import("./pages/AccountKeys"));
43+
const Remodel = importComponent(() => import("./pages/Remodel"));
4344

4445
Sentry.init({
4546
dsn: window.SENTRY_DSN,
@@ -124,6 +125,7 @@ root.render(
124125
<Route index element={<Model />} />
125126
<Route path="policies" element={<Policies />} />
126127
<Route path="policies/create" element={<Policies />} />
128+
<Route path="remodel" element={<Remodel />} />
127129
</Route>
128130
</Route>
129131
</Route>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
2626
Policies
2727
</Link>
2828
</li>
29+
<li className="p-tabs__item">
30+
<Link
31+
to={`/admin/${id}/models/${model_id}/remodel`}
32+
className="p-tabs__link"
33+
aria-selected={sectionName === "remodel"}
34+
role="tab"
35+
>
36+
Remodel
37+
</Link>
38+
</li>
2939
</ul>
3040
</nav>
3141
);

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,24 @@ import ModelNav from "./ModelNav";
55
function ModelDetailsPageLayout() {
66
const { pathname } = useLocation();
77
const isPolicies = pathname.endsWith("policies");
8+
const isRemodel = pathname.endsWith("remodel");
9+
10+
const getSectionName = () => {
11+
if (isPolicies) {
12+
return "policies";
13+
}
14+
15+
if (isRemodel) {
16+
return "remodel";
17+
}
18+
19+
return "overview";
20+
};
821

922
return (
1023
<>
1124
<ModelBreadcrumb />
12-
<ModelNav sectionName={isPolicies ? "policies" : "overview"} />
25+
<ModelNav sectionName={getSectionName()} />
1326
<Outlet />
1427
</>
1528
);

static/js/publisher/pages/Models/Models.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ function Models(): React.JSX.Element {
6060
const signal = controller.signal;
6161

6262
if (!modelsIsLoading && !modelsError && models) {
63+
const modelIds = [...new Set(models.map((m) => m.name))];
6364
setModelsList(models);
6465
setFilter(searchParams.get("filter") || "");
65-
getPolicies({ models, id, setPolicies, signal });
66+
getPolicies({ modelIds, id, setPolicies, signal });
6667
}
6768

6869
return () => {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useEffect } from "react";
2+
import { useAtomValue, useSetAtom } from "jotai";
3+
import { useParams, useSearchParams } from "react-router-dom";
4+
import { Notification, Icon, Row, Col } from "@canonical/react-components";
5+
6+
import { useRemodels } from "../../hooks";
7+
import {
8+
remodelsListFilterState,
9+
remodelsListState,
10+
} from "../../state/remodelsState";
11+
import { policiesListState } from "../../state/policiesState";
12+
import { brandIdState, brandStoreState } from "../../state/brandStoreState";
13+
import { setPageTitle, getPolicies } from "../../utils";
14+
15+
import Filter from "../../components/Filter";
16+
import RemodelTable from "./RemodelTable";
17+
18+
import type { UseQueryResult } from "react-query";
19+
import type { Remodel } from "../../types/shared";
20+
21+
function Remodel(): React.JSX.Element {
22+
const { id } = useParams();
23+
const brandId = useAtomValue(brandIdState);
24+
const { isLoading, isError, error, data }: UseQueryResult<Remodel[], Error> =
25+
useRemodels(brandId);
26+
const setRemodels = useSetAtom(remodelsListState);
27+
const setPolicies = useSetAtom(policiesListState);
28+
const setFilter = useSetAtom(remodelsListFilterState);
29+
const brandStore = useAtomValue(brandStoreState(id));
30+
const [searchParams] = useSearchParams();
31+
32+
brandStore
33+
? setPageTitle(`Remodels in ${brandStore.name}`)
34+
: setPageTitle("Remodels");
35+
36+
useEffect(() => {
37+
const controller = new AbortController();
38+
const signal = controller.signal;
39+
40+
if (!isLoading && !isError && data) {
41+
const modelIds = [...new Set(data.map((r) => r["to-model"]))];
42+
setRemodels(data);
43+
setFilter(searchParams.get("filter") || "");
44+
getPolicies({ modelIds, id, setPolicies, signal });
45+
}
46+
47+
return () => {
48+
controller.abort();
49+
};
50+
}, [isLoading, error, data, brandId, id]);
51+
52+
return (
53+
<>
54+
<div className="u-fixed-width u-flex-column u-flex-grow">
55+
{isError && error && (
56+
<Notification severity="negative">
57+
Error: {error.message}
58+
</Notification>
59+
)}
60+
{isLoading ? (
61+
<p>
62+
<Icon name="spinner" className="u-animation--spin" />
63+
&nbsp;Fetching remodels...
64+
</p>
65+
) : (
66+
<>
67+
<Row>
68+
<Col size={6}>
69+
<Filter
70+
state={remodelsListFilterState}
71+
label="Search remodels"
72+
placeholder="Search remodels"
73+
/>
74+
</Col>
75+
</Row>
76+
<div className="u-flex-column u-flex-grow">
77+
<RemodelTable />
78+
</div>
79+
</>
80+
)}
81+
</div>
82+
</>
83+
);
84+
}
85+
86+
export default Remodel;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useAtomValue } from "jotai";
2+
import { MainTable } from "@canonical/react-components";
3+
import { format } from "date-fns";
4+
5+
import { filteredRemodelsListState } from "../../state/remodelsState";
6+
7+
import type { Remodel } from "../../types/shared";
8+
9+
function RemodelTable(): React.JSX.Element {
10+
const remodels = useAtomValue(filteredRemodelsListState);
11+
12+
const headers = [
13+
{ content: "Target model", sortKey: "to-model" },
14+
{ content: "Original model", sortKey: "from-model" },
15+
{ content: "Allowed devices", className: "u-align--right" },
16+
{
17+
content: "Created date",
18+
className: "u-align--right",
19+
sortKey: "created-at",
20+
},
21+
{ content: "Note" },
22+
];
23+
24+
const rows = remodels.map((remodel: Remodel) => {
25+
return {
26+
columns: [
27+
{ content: remodel["to-model"] },
28+
{ content: remodel["from-model"] },
29+
{ content: remodel["serials"], className: "u-align--right" },
30+
{
31+
content: format(new Date(remodel["created-at"]), "dd/MM/yyyy"),
32+
className: "u-align--right",
33+
},
34+
{ content: remodel["description"] },
35+
],
36+
sortData: {
37+
"to-model": remodel["to-model"],
38+
"from-model": remodel["from-model"],
39+
"created-at": remodel["created-at"],
40+
},
41+
};
42+
});
43+
44+
return (
45+
<MainTable
46+
data-testid="remodels-table"
47+
sortable
48+
emptyStateMsg="No remodels match this filter"
49+
headers={headers}
50+
rows={rows}
51+
/>
52+
);
53+
}
54+
55+
export default RemodelTable;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./Remodel";

0 commit comments

Comments
 (0)