Skip to content

Commit 66fe5fb

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

14 files changed

Lines changed: 384 additions & 8 deletions

File tree

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

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)