Skip to content

Commit f44a3fa

Browse files
committed
feat: Add serial log table
Update serial log table columns and date Update tests Add serial log endpoint Update table Rename serial logs hook Rename everything Add serial log page Add useSerial hook Make page for serial log Cleanup
1 parent 4509b09 commit f44a3fa

14 files changed

Lines changed: 464 additions & 34 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.4.10
9+
canonicalwebteam.store-api==7.7.0
1010
canonicalwebteam.launchpad==0.9.0
1111
django-openid-auth==0.17
1212
Flask-OpenID==1.3.1
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 useSerialLogs from "../useSerialLogs";
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 serialLogData = [
25+
{
26+
"brand-id": "test-brand",
27+
"created-at": "2023-09-05T11:55:53.732366",
28+
"model-name": "test-model",
29+
serial: "test-serial",
30+
},
31+
];
32+
33+
const handlers = [
34+
http.get("/api/store/test-brand/models/test-model/serial-log", () => {
35+
return HttpResponse.json({
36+
data: serialLogData,
37+
message: "",
38+
success: true,
39+
});
40+
}),
41+
http.get("/api/store/test-brand-fail/models/test-model/serial-log", () => {
42+
return HttpResponse.json({
43+
data: [],
44+
message: "There was a problem fetching serial logs",
45+
success: false,
46+
});
47+
}),
48+
http.get("/api/store/test-brand-error/models/test-model/serial-log", () => {
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("useSerialLogs", () => {
69+
test("returns serial log data", async () => {
70+
const { result } = renderHook(
71+
() => useSerialLogs("test-brand", "test-model"),
72+
{
73+
wrapper: createWrapper(),
74+
},
75+
);
76+
77+
await waitFor(() => {
78+
expect(result.current.isSuccess).toBe(true);
79+
});
80+
81+
expect(result.current.data).toEqual(serialLogData);
82+
});
83+
84+
test("returns error if request fails", async () => {
85+
const { result } = renderHook(
86+
() => useSerialLogs("test-brand-fail", "test-model"),
87+
{
88+
wrapper: createWrapper(),
89+
},
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(
101+
() => useSerialLogs("test-brand-error", "test-model"),
102+
{
103+
wrapper: createWrapper(),
104+
},
105+
);
106+
107+
await waitFor(() => {
108+
expect(result.current.isError).toBe(true);
109+
});
110+
111+
expect(result.current.data).toBeUndefined();
112+
});
113+
});

static/js/publisher/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import useFetchPublishedSnapMetrics from "./useFetchPublishedSnapMetrics";
1616
import useSortTableData from "./useSortTableData";
1717
import useAccountKeys from "./useAccountKeys";
1818
import useRemodels from "./useRemodels";
19+
import useSerialLogs from "./useSerialLogs";
1920

2021
export {
2122
useValidationSets,
@@ -36,4 +37,5 @@ export {
3637
useSortTableData,
3738
useAccountKeys,
3839
useRemodels,
40+
useSerialLogs,
3941
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useQuery, UseQueryResult } from "react-query";
2+
import type { SerialLog } from "../types/shared";
3+
4+
const useSerialLogs = (
5+
brandId: string | undefined,
6+
modelId: string | undefined,
7+
): UseQueryResult<SerialLog[], Error> => {
8+
return useQuery<SerialLog[], Error>({
9+
queryKey: ["serialLogs", brandId, modelId],
10+
queryFn: async () => {
11+
const response = await fetch(
12+
`/api/store/${brandId}/models/${modelId}/serial-log`,
13+
);
14+
15+
if (!response.ok) {
16+
throw new Error("There was a problem fetching serial logs");
17+
}
18+
19+
const data = await response.json();
20+
21+
if (!data.success) {
22+
throw new Error(data.message);
23+
}
24+
25+
return data.data;
26+
},
27+
enabled: !!brandId,
28+
});
29+
};
30+
31+
export default useSerialLogs;

static/js/publisher/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const ValidationSet = importComponent(() => import("./pages/ValidationSet"));
4141
const ValidationSets = importComponent(() => import("./pages/ValidationSets"));
4242
const AccountKeys = importComponent(() => import("./pages/AccountKeys"));
4343
const Remodel = importComponent(() => import("./pages/Remodel"));
44+
const SerialLogs = importComponent(() => import("./pages/SerialLogs"));
4445

4546
Sentry.init({
4647
dsn: window.SENTRY_DSN,
@@ -127,6 +128,7 @@ root.render(
127128
<Route path="policies/create" element={<Policies />} />
128129
<Route path="remodel" element={<Remodel />} />
129130
<Route path="remodel/configure" element={<Remodel />} />
131+
<Route path="serial-log" element={<SerialLogs />} />
130132
</Route>
131133
</Route>
132134
<Route path="*" element={<Navigate to="../snaps" replace />} />
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 { useSerialLogs } from "../../hooks";
7+
import {
8+
serialLogsListState,
9+
serialLogsListFilterState,
10+
} from "../../state/serialLogsState";
11+
import { brandIdState, brandStoreState } from "../../state/brandStoreState";
12+
import { setPageTitle } from "../../utils";
13+
14+
import Filter from "../../components/Filter";
15+
import SerialLogsTable from "./SerialLogsTable";
16+
17+
import type { UseQueryResult } from "react-query";
18+
import type { SerialLog } from "../../types/shared";
19+
20+
function SerialLogs() {
21+
const { id, modelId } = useParams();
22+
const brandId = useAtomValue(brandIdState);
23+
const {
24+
isLoading,
25+
isError,
26+
error,
27+
data,
28+
}: UseQueryResult<SerialLog[], Error> = useSerialLogs(brandId, modelId);
29+
const setSerialLogs = useSetAtom(serialLogsListState);
30+
const setFilter = useSetAtom(serialLogsListFilterState);
31+
const brandStore = useAtomValue(brandStoreState(id));
32+
const [searchParams] = useSearchParams();
33+
34+
brandStore
35+
? setPageTitle(`Serial logs for ${modelId} in ${brandStore.name}`)
36+
: setPageTitle("Serial logs");
37+
38+
useEffect(() => {
39+
if (!isLoading && !isError && data) {
40+
setSerialLogs(data);
41+
setFilter(searchParams.get("filter") || "");
42+
}
43+
}, [isLoading, data, error, brandId, id]);
44+
45+
return (
46+
<div className="u-fixed-width u-flex-column u-flex-grow">
47+
{isError && error && (
48+
<Notification severity="negative">Error: {error.message}</Notification>
49+
)}
50+
{isLoading ? (
51+
<p>
52+
<Icon name="spinner" className="u-animation--spin" />
53+
&nbsp;Fetching serial logs...
54+
</p>
55+
) : (
56+
<>
57+
<Row>
58+
<Col size={6}>
59+
<Filter
60+
state={serialLogsListFilterState}
61+
label="Search serial logs"
62+
placeholder="Search serial logs"
63+
/>
64+
</Col>
65+
</Row>
66+
<div className="u-flex-column u-flex-grow">
67+
<SerialLogsTable />
68+
</div>
69+
</>
70+
)}
71+
</div>
72+
);
73+
}
74+
75+
export default SerialLogs;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useAtomValue } from "jotai";
2+
import { useParams } from "react-router-dom";
3+
import { MainTable, TablePagination } from "@canonical/react-components";
4+
import { format } from "date-fns";
5+
6+
import { brandStoreState } from "../../state/brandStoreState";
7+
import { filteredSerialLogsListState } from "../../state/serialLogsState";
8+
import { useSortTableData } from "../../hooks";
9+
10+
import type { SerialLog } from "../../types/shared";
11+
12+
function SerialLogsTable(): React.JSX.Element {
13+
const { id } = useParams();
14+
const serialLogs = useAtomValue(filteredSerialLogsListState);
15+
const brandStore = useAtomValue(brandStoreState(id));
16+
17+
const headers = [
18+
{ content: "Brand" },
19+
{ content: "Model" },
20+
{ content: "Serial number" },
21+
{ content: "Date", sortKey: "created-at" },
22+
];
23+
24+
const rows = serialLogs.map((serialLog: SerialLog) => {
25+
return {
26+
columns: [
27+
{ content: brandStore?.name },
28+
{ content: serialLog["model-name"] },
29+
{ content: serialLog.serial },
30+
{
31+
content: format(
32+
new Date(serialLog["created-at"]),
33+
"dd/MM/yyyy 'at' HH:mm",
34+
),
35+
},
36+
],
37+
sortData: {
38+
"created-at": serialLog["created-at"],
39+
},
40+
};
41+
});
42+
43+
const { rows: sortedRows, updateSort } = useSortTableData({ rows });
44+
45+
return (
46+
<TablePagination
47+
data={sortedRows}
48+
pageLimits={[25, 50, 100, 200]}
49+
position="below"
50+
>
51+
<MainTable
52+
data-testid="serial-log-table"
53+
sortable
54+
emptyStateMsg="No serial logs match this filter"
55+
headers={headers}
56+
onUpdateSort={updateSort}
57+
/>
58+
</TablePagination>
59+
);
60+
}
61+
62+
export default SerialLogsTable;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { BrowserRouter } from "react-router-dom";
2+
import { QueryClient, QueryClientProvider } from "react-query";
3+
import { render, screen } from "@testing-library/react";
4+
5+
import "@testing-library/jest-dom";
6+
7+
import SerialLogs from "../SerialLogs";
8+
9+
const mockFilterQuery = "test-serial";
10+
11+
vi.mock("react-router-dom", async (importOriginal) => ({
12+
...(await importOriginal()),
13+
useSearchParams: () => [new URLSearchParams({ filter: mockFilterQuery })],
14+
}));
15+
16+
vi.mock("../../Portals/Portals", async (importOriginal) => ({
17+
...(await importOriginal()),
18+
PortalEntrance: ({ children }: { children: React.ReactNode }) => (
19+
<>{children}</>
20+
),
21+
}));
22+
23+
const queryClient = new QueryClient({
24+
defaultOptions: {
25+
queries: {
26+
refetchOnWindowFocus: false,
27+
refetchOnReconnect: false,
28+
},
29+
},
30+
});
31+
32+
function renderComponent() {
33+
return render(
34+
<BrowserRouter>
35+
<QueryClientProvider client={queryClient}>
36+
<SerialLogs />
37+
</QueryClientProvider>
38+
</BrowserRouter>,
39+
);
40+
}
41+
42+
describe("SerialLogs", () => {
43+
it("displays a filter input", () => {
44+
renderComponent();
45+
expect(screen.getByLabelText("Search serial logs")).toBeInTheDocument();
46+
});
47+
48+
it("populates filter with the filter query parameter", () => {
49+
renderComponent();
50+
expect(screen.getByLabelText("Search serial logs")).toHaveValue(
51+
mockFilterQuery,
52+
);
53+
});
54+
});

0 commit comments

Comments
 (0)