Skip to content

Commit a776b49

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 b81ac36 commit a776b49

14 files changed

Lines changed: 377 additions & 24 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 />} />

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
3636
Remodel
3737
</Link>
3838
</li>
39+
<li className="p-tabs__item">
40+
<Link
41+
to={`/admin/${id}/models/${modelId}/serial-log`}
42+
className="p-tabs__link"
43+
aria-selected={sectionName === "serialLog"}
44+
role="tab"
45+
>
46+
Serial log
47+
</Link>
48+
</li>
3949
</ul>
4050
</nav>
4151
);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function ModelDetailsPageLayout() {
88
pathname.endsWith("policies") || pathname.endsWith("policies/create");
99
const isRemodel =
1010
pathname.endsWith("remodel") || pathname.endsWith("remodel/configure");
11+
const isSerialLog = pathname.endsWith("serial-log");
1112

1213
const getSectionName = () => {
1314
if (isPolicies) {
@@ -18,6 +19,10 @@ function ModelDetailsPageLayout() {
1819
return "remodel";
1920
}
2021

22+
if (isSerialLog) {
23+
return "serialLog";
24+
}
25+
2126
return "overview";
2227
};
2328

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./SerialLogs";

0 commit comments

Comments
 (0)