Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ canonicalwebteam.discourse==7.2.0
canonicalwebteam.blog==6.8.4
canonicalwebteam.search==2.1.2
canonicalwebteam.image-template==1.9.0
canonicalwebteam.store-api==7.8.2
canonicalwebteam.store-api==7.8.3
canonicalwebteam.launchpad==0.9.0
django-openid-auth==0.17
Flask-OpenID==1.3.1
Expand Down
12 changes: 6 additions & 6 deletions static/js/publisher/hooks/__tests__/useSerialLogs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,14 @@ describe("useSerialLogs", () => {
});
});

test("returns serial logs data with nextPage param", async () => {
test("returns serial logs data with page param", async () => {
server.use(
http.get(
"/api/store/test-brand-id/models/test-model/serial-log",
({ request }) => {
const url = new URL(request.url);

expect(url.searchParams.get("next-page")).toBe("nextpagecursor");
expect(url.searchParams.get("page")).toBe("pagecursor");
return HttpResponse.json({
data: serialLogsResponse,
success: true,
Expand All @@ -188,7 +188,7 @@ describe("useSerialLogs", () => {
const { result } = renderHook(
() =>
useSerialLogs("test-brand-id", "test-model", {
nextPage: "nextpagecursor",
page: "pagecursor",
}),
{
wrapper: createWrapper(),
Expand All @@ -205,7 +205,7 @@ describe("useSerialLogs", () => {
});
});

test("returns serial logs data with startTime, endTime, pageSize and nextPage params", async () => {
test("returns serial logs data with startTime, endTime, pageSize and page params", async () => {
server.use(
http.get(
"/api/store/test-brand-id/models/test-model/serial-log",
Expand All @@ -218,7 +218,7 @@ describe("useSerialLogs", () => {
"2026-03-28T04:00:23.875000",
);
expect(url.searchParams.get("page-size")).toBe("10");
expect(url.searchParams.get("next-page")).toBe("nextpagecursor");
expect(url.searchParams.get("page")).toBe("pagecursor");
return HttpResponse.json({
data: serialLogsResponse,
success: true,
Expand All @@ -235,7 +235,7 @@ describe("useSerialLogs", () => {
endTime: "2026-03-28T04:00:23.875000",
},
pageSize: 10,
nextPage: "nextpagecursor",
page: "pagecursor",
}),
{
wrapper: createWrapper(),
Expand Down
19 changes: 13 additions & 6 deletions static/js/publisher/hooks/useSerialLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ const useSerialLogs = (
brandId: string | undefined,
modelId: string | undefined,
urlSearchParams?: {
page?: string | null;
pageSize?: number;
nextPage?: string;
interval?: {
startTime: string;
endTime: string;
Expand All @@ -19,7 +19,7 @@ const useSerialLogs = (
);

if (urlSearchParams) {
const { interval, pageSize, nextPage } = urlSearchParams;
const { interval, pageSize, page } = urlSearchParams;

if (interval) {
url.searchParams.set("start-time", interval.startTime);
Expand All @@ -30,15 +30,22 @@ const useSerialLogs = (
url.searchParams.set("page-size", pageSize.toString());
}

if (nextPage) {
url.searchParams.set("next-page", nextPage);
if (page) {
url.searchParams.set("page", page);
}
}

return useQuery<ApiResponse<SerialLogResponse>, Error>({
queryKey: ["serials", brandId, modelId, urlSearchParams],
queryKey: [
"serials",
brandId,
modelId,
urlSearchParams?.page,
urlSearchParams?.pageSize,
urlSearchParams?.interval,
],
queryFn: async () => {
const response = await fetch(url.toString());
const response = await fetch(url);
const responseData = await response.json();

return responseData;
Expand Down
15 changes: 14 additions & 1 deletion static/js/publisher/layouts/ModelDetailsPageLayout/ModelNav.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Link, useParams } from "react-router-dom";
import { useAtomValue } from "jotai";

import { useRemodels } from "../../hooks";
import { useRemodels, useSerialLogs } from "../../hooks";
import { brandIdState } from "../../state/brandStoreState";

function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
const { id, modelId } = useParams();
const brandId = useAtomValue(brandIdState);
const { data: remodelsData } = useRemodels(brandId);
const { data: serialLogsData } = useSerialLogs(brandId, modelId);

return (
<nav className="p-tabs">
Expand Down Expand Up @@ -44,6 +45,18 @@ function ModelNav({ sectionName }: { sectionName: string }): React.JSX.Element {
</Link>
</li>
)}
{serialLogsData?.success && (
<li className="p-tabs__item">
<Link
to={`/admin/${id}/models/${modelId}/serial-log`}
className="p-tabs__link"
aria-selected={sectionName === "serial-log"}
role="tab"
>
Serial log
</Link>
</li>
)}
</ul>
</nav>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { vi } from "vitest";
import "@testing-library/jest-dom";

import ModelNav from "../ModelNav";
import { useRemodels } from "../../../hooks";
import { useRemodels, useSerialLogs } from "../../../hooks";

import type { UseQueryResult } from "react-query";
import type { ApiResponse, RemodelResponse } from "../../../types/shared";
import type {
ApiResponse,
RemodelResponse,
SerialLogResponse,
} from "../../../types/shared";

vi.mock("../../../hooks", () => ({
useRemodels: vi.fn(),
useSerialLogs: vi.fn(),
}));

vi.mock("../../../state/brandStoreState", () => ({
Expand Down Expand Up @@ -47,6 +52,17 @@ describe("ModelNav", () => {
},
} as unknown as UseQueryResult<ApiResponse<RemodelResponse>, Error>);

const mockUseSerialLogs = vi.mocked(useSerialLogs);
mockUseSerialLogs.mockReturnValue({
data: {
success: true,
data: {
items: [],
"next-cursor": null,
},
},
} as unknown as UseQueryResult<ApiResponse<SerialLogResponse>, Error>);

renderComponent("policies");
const currentLink = screen.getByRole("tab", { name: "Policies" });
expect(currentLink.getAttribute("aria-selected")).toBe("true");
Expand All @@ -64,6 +80,17 @@ describe("ModelNav", () => {
},
} as unknown as UseQueryResult<ApiResponse<RemodelResponse>, Error>);

const mockUseSerialLogs = vi.mocked(useSerialLogs);
mockUseSerialLogs.mockReturnValue({
data: {
success: true,
data: {
items: [],
"next-cursor": null,
},
},
} as unknown as UseQueryResult<ApiResponse<SerialLogResponse>, Error>);

renderComponent("policies");
const currentLink = screen.getByRole("tab", { name: "Overview" });
expect(currentLink.getAttribute("aria-selected")).toBe("false");
Expand All @@ -81,6 +108,17 @@ describe("ModelNav", () => {
},
} as unknown as UseQueryResult<ApiResponse<RemodelResponse>, Error>);

const mockUseSerialLogs = vi.mocked(useSerialLogs);
mockUseSerialLogs.mockReturnValue({
data: {
success: true,
data: {
items: [],
"next-cursor": null,
},
},
} as unknown as UseQueryResult<ApiResponse<SerialLogResponse>, Error>);

renderComponent("overview");
expect(screen.getByRole("tab", { name: "Remodel" })).toBeInTheDocument();
});
Expand All @@ -98,6 +136,17 @@ describe("ModelNav", () => {
},
} as unknown as UseQueryResult<ApiResponse<RemodelResponse>, Error>);

const mockUseSerialLogs = vi.mocked(useSerialLogs);
mockUseSerialLogs.mockReturnValue({
data: {
success: false,
data: {
items: [],
"next-cursor": null,
},
},
} as unknown as UseQueryResult<ApiResponse<SerialLogResponse>, Error>);

renderComponent("overview");
expect(
screen.queryByRole("tab", { name: "Remodel" }),
Expand All @@ -110,6 +159,11 @@ describe("ModelNav", () => {
data: undefined,
} as unknown as UseQueryResult<ApiResponse<RemodelResponse>, Error>);

const mockUseSerialLogs = vi.mocked(useSerialLogs);
mockUseSerialLogs.mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<ApiResponse<SerialLogResponse>, Error>);

renderComponent("overview");
expect(
screen.queryByRole("tab", { name: "Remodel" }),
Expand Down
5 changes: 4 additions & 1 deletion static/js/publisher/pages/Remodel/Remodel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ function Remodel(): React.JSX.Element {
// because otherwise the cursor history gets out of sync
setCurrentCursor(null);
cursorHistory.current = [];
setSearchParams({ "page-size": newPageSize.toString() });
setSearchParams((params) => {
params.set("page-size", newPageSize.toString());
return params;
});
};

brandStore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ const renderComponent = () => {
return render(
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<RemodelTable />
<RemodelTable
handlePageForward={vi.fn()}
handlePageBack={vi.fn()}
handlePageSizeChange={vi.fn()}
forwardDisabled={false}
backDisabled={true}
pageSize={10}
/>
</QueryClientProvider>
</BrowserRouter>,
);
Expand Down
60 changes: 56 additions & 4 deletions static/js/publisher/pages/SerialLog/SerialLog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useEffect, useState, useRef } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { Notification, Icon } from "@canonical/react-components";

import { useSerialLogs } from "../../hooks";
Expand All @@ -15,7 +15,23 @@ import type { SerialLogResponse, ApiResponse } from "../../types/shared";

function SerialLog(): React.JSX.Element {
const { id, modelId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const brandId = useAtomValue(brandIdState);
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const cursorHistory = useRef<Array<string | null>>([]);

const pageSizeParam = searchParams.get("page-size");
const parsedPageSize = pageSizeParam ? parseInt(pageSizeParam) : NaN;
const startTime = searchParams.get("start-time");
const endTime = searchParams.get("end-time");
const pageSize = Number.isInteger(parsedPageSize) ? parsedPageSize : 25;
const params = {
pageSize: pageSize,
page: currentCursor,
...(startTime && endTime && { interval: { startTime, endTime } }),
};

const {
isLoading,
isError,
Expand All @@ -24,17 +40,44 @@ function SerialLog(): React.JSX.Element {
}: UseQueryResult<ApiResponse<SerialLogResponse>, Error> = useSerialLogs(
brandId,
modelId,
params,
);
const setSerialLogs = useSetAtom(serialLogsListState);
const brandStore = useAtomValue(brandStoreState(id));

const handlePageForward = () => {
cursorHistory.current.push(currentCursor);
setCurrentCursor(nextCursor);
};

const handlePageBack = () => {
const lastCursor = cursorHistory.current.pop();
setCurrentCursor(lastCursor || null);
};

const handlePageSizeChange = (newPageSize: number) => {
// Need to reset current page when changing page size
// because otherwise the cursor history gets out of sync
setCurrentCursor(null);
cursorHistory.current = [];
setSearchParams((params) => {
params.set("page-size", newPageSize.toString());
return params;
});
};

brandStore
? setPageTitle(`Serial logs in ${brandStore.name}`)
: setPageTitle("Serial logs");

useEffect(() => {
if (!isLoading && !isError && data) {
if (isLoading || isError) {
return;
}

if (data) {
setSerialLogs(data.data?.items || []);
setNextCursor(data.data?.["next-cursor"] || null);
}
}, [isLoading, isError, data]);

Expand All @@ -57,7 +100,16 @@ function SerialLog(): React.JSX.Element {
</Notification>
) : (
<div className="u-flex-column u-flex-grow">
<SerialLogTable />
<SerialLogTable
handlePageForward={handlePageForward}
handlePageBack={handlePageBack}
handlePageSizeChange={handlePageSizeChange}
forwardDisabled={!nextCursor}
backDisabled={
cursorHistory.current.length < 1 || currentCursor === null
}
pageSize={pageSize}
/>
</div>
)}
</div>
Expand Down
Loading
Loading