sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #07794
[Merge] ~petermakowski/maas-site-manager:invalidate-queries-on-mutation into maas-site-manager:main
Peter Makowski has proposed merging ~petermakowski/maas-site-manager:invalidate-queries-on-mutation into maas-site-manager:main.
Commit message:
invalidate queries on mutation success
- fix displayed dates by using internal UTC formatting util functions
- update token post data to match back-end properties
Requested reviews:
MAAS Committers (maas-committers)
For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/442026
--
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:invalidate-queries-on-mutation into maas-site-manager:main.
diff --git a/frontend/src/api/handlers.test.ts b/frontend/src/api/handlers.test.ts
index 02f92f6..c03cbc3 100644
--- a/frontend/src/api/handlers.test.ts
+++ b/frontend/src/api/handlers.test.ts
@@ -2,6 +2,7 @@ import { setupServer } from "msw/node";
import { patchEnrollmentRequests, postTokens } from "./handlers";
+import { durationFactory } from "@/mocks/factories";
import {
postTokens as postTokensResolver,
patchEnrollmentRequests as postEnrollmentRequestsResolver,
@@ -23,7 +24,7 @@ describe("postTokens handler", () => {
it("requires name, amount and expiration time", async () => {
// @ts-expect-error
await expect(postTokens({})).rejects.toThrowError();
- await expect(postTokens({ amount: 1, expires: "P0Y0M7DT0H0M0S" })).resolves.toEqual(
+ await expect(postTokens({ amount: 1, duration: durationFactory.build() })).resolves.toEqual(
expect.objectContaining({
items: expect.any(Array),
}),
diff --git a/frontend/src/api/handlers.ts b/frontend/src/api/handlers.ts
index 046483d..70d4775 100644
--- a/frontend/src/api/handlers.ts
+++ b/frontend/src/api/handlers.ts
@@ -1,6 +1,7 @@
import * as Sentry from "@sentry/browser";
import api from "./api";
+import type { Token } from "./types";
import urls from "./urls";
import { customParamSerializer } from "@/utils";
@@ -50,10 +51,10 @@ export const getSites = async (params: GetSitesQueryParams, queryText?: string)
export type PostTokensData = {
amount: number;
name?: string;
- expires: string; // <ISO 8601 date string>,
+ duration: string; // <ISO 8601 duration string>,
};
export const postTokens = async (data: PostTokensData) => {
- if (!data?.amount || !data?.expires) {
+ if (!data?.amount || !data?.duration) {
throw Error("Missing required fields");
}
try {
@@ -74,7 +75,7 @@ export const getTokens = async (params: GetTokensQueryParams) => {
}
};
-export const deleteTokens = async (data: string[]) => {
+export const deleteTokens = async (data: Token["id"][]) => {
if (data.length === 0) {
throw Error("No tokens selected");
}
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index e4ff44a..248c6ac 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -35,10 +35,10 @@ export type PaginatedQueryResult<D extends unknown> = {
export type SitesQueryResult = PaginatedQueryResult<Site>;
export type Token = {
- id: string;
+ id: number;
site_id: Site["id"] | null;
value: string;
- expires: string; //<ISO 8601 date string>,
+ expired: string; //<ISO 8601 date string>,
created: string; //<ISO 8601 date string>
};
export type PostTokensResult = PaginatedQueryResult<Token>;
diff --git a/frontend/src/components/TokensCreate/TokensCreate.test.tsx b/frontend/src/components/TokensCreate/TokensCreate.test.tsx
index 25525c2..396a777 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.test.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.test.tsx
@@ -14,7 +14,7 @@ const tokensMutationMock = vi.fn();
vi.mock("@/hooks/api", async (importOriginal) => {
const original: typeof apiHooks = await importOriginal();
- return { ...original, useTokensMutation: () => ({ mutateAsync: tokensMutationMock }) };
+ return { ...original, useTokensCreateMutation: () => ({ mutateAsync: tokensMutationMock }) };
});
beforeAll(() => {
@@ -67,7 +67,7 @@ it("can generate enrolment tokens", async () => {
expect(tokensMutationMock).toHaveBeenCalledTimes(1);
expect(tokensMutationMock).toHaveBeenCalledWith({
amount: 1,
- expires: "P7DT0H0M0S",
+ duration: "P7DT0H0M0S",
});
});
diff --git a/frontend/src/components/TokensCreate/TokensCreate.tsx b/frontend/src/components/TokensCreate/TokensCreate.tsx
index 4f8e32d..b9e415e 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.tsx
@@ -8,7 +8,7 @@ import * as Yup from "yup";
import { humanIntervalToISODuration } from "./utils";
import { useAppContext } from "@/context";
-import { useTokensMutation } from "@/hooks/api";
+import { useTokensCreateMutation } from "@/hooks/api";
const initialValues = {
amount: "",
@@ -41,18 +41,16 @@ const TokensCreate = () => {
const headingId = useId();
const expiresId = useId();
const amountId = useId();
- const tokensMutation = useTokensMutation();
+ const tokensCreateMutation = useTokensCreateMutation();
const { setSidebar } = useAppContext();
const handleSubmit = async (
{ amount, expires }: TokensCreateFormValues,
{ setSubmitting }: FormikHelpers<TokensCreateFormValues>,
) => {
- await tokensMutation.mutateAsync({
+ await tokensCreateMutation.mutateAsync({
amount: Number(amount),
- expires: humanIntervalToISODuration(expires) as string,
+ duration: humanIntervalToISODuration(expires) as string,
});
- // TODO: update the tokens list once fetching tokens from API is implemented
- // https://warthogs.atlassian.net/browse/MAASENG-1474
setSubmitting(false);
setSidebar(null);
};
@@ -62,7 +60,7 @@ const TokensCreate = () => {
<h3 className="tokens-create__heading p-heading--4" id={headingId}>
Generate new enrolment tokens
</h3>
- {tokensMutation.isError && (
+ {tokensCreateMutation.isError && (
<Notification severity="negative">There was an error generating the token(s).</Notification>
)}
<Formik
@@ -104,7 +102,7 @@ const TokensCreate = () => {
</Button>
<Button
appearance="positive"
- disabled={!dirty || !isValid || tokensMutation.isLoading || isSubmitting}
+ disabled={!dirty || !isValid || tokensCreateMutation.isLoading || isSubmitting}
type="submit"
>
Generate tokens
diff --git a/frontend/src/components/TokensCreate/utils.ts b/frontend/src/components/TokensCreate/utils.ts
index d23b6d2..3ba19c0 100644
--- a/frontend/src/components/TokensCreate/utils.ts
+++ b/frontend/src/components/TokensCreate/utils.ts
@@ -1,6 +1,12 @@
import humanInterval from "human-interval";
-function intervalToDuration(ms: number) {
+/**
+ * @param {number} ms - milliseconds
+ * @returns {object} - object with days, hours, minutes and seconds
+ * @example
+ * intervalToDuration(1000) // { days: 0, hours: 0, minutes: 0, seconds: 1 }
+ */
+const intervalToDuration = (ms: number) => {
let seconds = Math.floor(ms / 1000);
const days = Math.floor(seconds / (24 * 3600));
seconds %= 24 * 3600;
@@ -14,13 +20,15 @@ function intervalToDuration(ms: number) {
minutes,
seconds,
};
-}
+};
+
+const intervalToISODuration = (intervalNumber: number): string => {
+ const duration = intervalToDuration(intervalNumber);
+ return `P${duration.days}DT${duration.hours}H${duration.minutes}M${duration.seconds}S`;
+};
// return ISO 8601 duration only using days, hours, minutes and seconds
-export const humanIntervalToISODuration = (intervalString: string) => {
+export const humanIntervalToISODuration = (intervalString: string): string | null => {
const intervalNumber = humanInterval(intervalString);
- if (intervalNumber) {
- const duration = intervalToDuration(intervalNumber);
- return `P${duration.days}DT${duration.hours}H${duration.minutes}M${duration.seconds}S`;
- }
+ return intervalNumber ? intervalToISODuration(intervalNumber) : null;
};
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index 9179a1c..1bc8d18 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -48,7 +48,7 @@ const TokensList = () => {
}, [data]);
const handleTokenDelete = () => {
- const selectedIds = isSuccess ? Object.keys(rowSelection).map((_, idx) => data.items[idx].id) : [];
+ const selectedIds = isSuccess ? Object.keys(rowSelection).map((_, idx) => Number(data.items[idx].id)) : [];
tokensDeleteMutation.mutate(selectedIds);
};
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx
index 26b7b61..289267c 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx
@@ -4,6 +4,14 @@ import type { Token } from "@/api/types";
import { tokenFactory } from "@/mocks/factories";
import { render, screen, within } from "@/test-utils";
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
it("displays the tokens table", () => {
render(<TokensTable data={{ items: [], total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
@@ -39,3 +47,23 @@ it("should display a no-tokens caption if there are no tokens", () => {
expect(screen.getByText(/No tokens available/i)).toBeInTheDocument();
});
+
+it("displays created date in UTC", () => {
+ const date = new Date("Fri Apr 21 2023 14:00:00 GMT+0200 (GMT)");
+ vi.setSystemTime(date);
+ const items = [tokenFactory.build({ created: "2023-04-21T11:30:00.000Z" })];
+
+ render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+
+ expect(screen.getByText(/2023-04-21 11:30/i)).toBeInTheDocument();
+});
+
+it("displays time until expiration in UTC", () => {
+ const date = new Date("Fri Apr 21 2023 14:00:00 GMT+0200 (GMT)");
+ vi.setSystemTime(date);
+ const items = [tokenFactory.build({ expired: "2023-04-21T14:00:00.000Z" })];
+
+ render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+
+ expect(screen.getByText(/in 2 hours/i)).toBeInTheDocument();
+});
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
index 531d875..c30491c 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from "react";
import type { ColumnDef, Column, Row, Getter } from "@tanstack/react-table";
import { flexRender, useReactTable, getCoreRowModel } from "@tanstack/react-table";
-import { format, formatDistanceStrict } from "date-fns";
import pick from "lodash/fp/pick";
import type { Token } from "@/api/types";
@@ -11,7 +10,7 @@ import CopyButton from "@/components/base/CopyButton";
import TooltipButton from "@/components/base/TooltipButton";
import { useAppContext } from "@/context";
import type { useTokensQueryResult } from "@/hooks/api";
-import { copyToClipboard } from "@/utils";
+import { copyToClipboard, formatDistanceToNow, formatUTCDateString } from "@/utils";
const createAccessor =
<T, K extends keyof T>(keys: K[] | K) =>
@@ -84,16 +83,13 @@ const TokensTable = ({
},
{
id: "expirationTime",
- accessorFn: createAccessor("expires"),
+ accessorFn: createAccessor("expired"),
header: () => <div>Time until expiration</div>,
cell: ({ getValue }) => {
- const { expires } = getValue();
+ const { expired } = getValue();
return (
- <TooltipButton
- message={expires ? `${format(new Date(expires), "yyyy-MM-dd HH:mm")} (UTC)` : null}
- position="btm-center"
- >
- {expires ? formatDistanceStrict(new Date(expires), new Date()) : null}
+ <TooltipButton message={expired ? `${formatUTCDateString(expired)} (UTC)` : null} position="btm-center">
+ {expired ? formatDistanceToNow(expired) : null}
</TooltipButton>
);
},
@@ -104,7 +100,7 @@ const TokensTable = ({
header: () => <div>Created (UTC)</div>,
cell: ({ getValue }) => {
const { created } = getValue();
- return <div>{created ? format(new Date(created), "yyyy-MM-dd HH:mm") : null}</div>;
+ return <div>{created ? formatUTCDateString(created) : null}</div>;
},
},
],
@@ -119,7 +115,7 @@ const TokensTable = ({
state: {
rowSelection,
},
- getRowId: (row) => row.id,
+ getRowId: (row) => `${row.id}`,
manualPagination: true,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/api.ts
index 40072cd..2ce8c1c 100644
--- a/frontend/src/hooks/api.ts
+++ b/frontend/src/hooks/api.ts
@@ -1,5 +1,5 @@
import type { UseMutationOptions, UseMutationResult } from "@tanstack/react-query";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useQueryClient, useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import type {
@@ -18,7 +18,13 @@ import {
getSites,
getTokens,
} from "@/api/handlers";
-import type { SitesQueryResult, PostTokensResult, EnrollmentRequestsQueryResult, AccessToken } from "@/api/types";
+import type {
+ SitesQueryResult,
+ PostTokensResult,
+ EnrollmentRequestsQueryResult,
+ AccessToken,
+ Token,
+} from "@/api/types";
export type UseSitesQueryResult = ReturnType<typeof useSitesQuery>;
@@ -40,10 +46,25 @@ export const useTokensQuery = ({ page, size }: GetTokensQueryParams) =>
keepPreviousData: true,
});
-export const useTokensMutation = () => useMutation(postTokens);
+export const useTokensCreateMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation(postTokens, {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tokens"] });
+ },
+ });
+};
-export const useDeleteTokensMutation = (options: UseMutationOptions<unknown, unknown, string[], unknown>) =>
- useMutation(deleteTokens, options);
+export const useDeleteTokensMutation = (options: UseMutationOptions<unknown, unknown, Token["id"][], unknown>) => {
+ const queryClient = useQueryClient();
+ return useMutation(deleteTokens, {
+ ...options,
+ onSuccess: (...args) => {
+ options?.onSuccess?.(...args);
+ queryClient.invalidateQueries({ queryKey: ["tokens"] });
+ },
+ });
+};
export type UseEnrollmentRequestsQueryResult = ReturnType<typeof useRequestsQuery>;
export const useRequestsQuery = ({ page, size }: GetEnrollmentRequestsQueryParams) =>
diff --git a/frontend/src/mocks/factories.ts b/frontend/src/mocks/factories.ts
index d0734fd..741be81 100644
--- a/frontend/src/mocks/factories.ts
+++ b/frontend/src/mocks/factories.ts
@@ -1,5 +1,5 @@
import Chance from "chance";
-import { sub } from "date-fns";
+import { sub, add } from "date-fns";
import { Factory } from "fishery";
import { uniqueNamesGenerator, adjectives, colors, animals } from "unique-names-generator";
@@ -58,14 +58,16 @@ export const paginatedQueryResultFactory = <T extends unknown>() =>
export const enrollmentRequestQueryResultFactory = paginatedQueryResultFactory<EnrollmentRequest>();
export const sitesQueryResultFactory = paginatedQueryResultFactory<Site>();
+export const durationFactory = Factory.define<string>(() => "P7DT0H0M0S");
export const tokenFactory = Factory.define<Token>(({ sequence }) => {
+ const now = new Date();
const chance = new Chance(`maas-${sequence}`);
return {
- id: `${sequence}`,
+ id: sequence,
site_id: `${chance.integer({ min: 0, max: 100 })}`,
value: chance.hash({ length: 64 }),
- expires: new Date(chance.date({ year: 2024 })).toISOString(), //<ISO 8601 date string>,
- created: new Date(chance.date({ year: 2023 })).toISOString(), //<ISO 8601 date string>
+ expired: new Date(chance.date({ min: add(now, { seconds: 1 }), max: add(now, { days: 1 }) })).toISOString(), //<ISO 8601 date string>,
+ created: new Date(chance.date({ min: sub(now, { minutes: 15 }), max: now })).toISOString(), //<ISO 8601 date string>
};
});
diff --git a/frontend/src/mocks/resolvers.test.ts b/frontend/src/mocks/resolvers.test.ts
index 3b10e9b..eca3832 100644
--- a/frontend/src/mocks/resolvers.test.ts
+++ b/frontend/src/mocks/resolvers.test.ts
@@ -1,7 +1,7 @@
import axios from "axios";
import urls from "@/api/urls";
-import { tokenFactory } from "@/mocks/factories";
+import { durationFactory } from "@/mocks/factories";
import { createMockTokensResolver } from "@/mocks/resolvers";
import { createMockPostServer } from "@/mocks/server";
@@ -18,11 +18,9 @@ afterAll(() => {
});
describe("mock post tokens server", () => {
- it("returns list of tokens based on the request data", async () => {
+ it("returns list of tokens", async () => {
const amount = 1;
- const { expires } = tokenFactory.build({ expires: "2021-01-01" });
- const result = await axios.post(urls.tokens, { expires, amount });
+ const result = await axios.post(urls.tokens, { duration: durationFactory.build(), amount });
expect(result.data.items).toHaveLength(amount);
- expect(result.data.items[0]).toEqual(expect.objectContaining({ expires }));
});
});
diff --git a/frontend/src/mocks/resolvers.ts b/frontend/src/mocks/resolvers.ts
index cdd19de..6816311 100644
--- a/frontend/src/mocks/resolvers.ts
+++ b/frontend/src/mocks/resolvers.ts
@@ -49,9 +49,9 @@ export const createMockSitesResolver =
type TokensResponseResolver = ResponseResolver<RestRequest<PostTokensData>, typeof restContext>;
export const createMockTokensResolver = (): TokensResponseResolver => async (req, res, ctx) => {
let items;
- const { amount, expires } = await req.json();
- if (amount && expires) {
- items = Array(amount).fill(tokenFactory.build({ expires }));
+ const { amount, duration } = await req.json();
+ if (amount && duration) {
+ items = Array(amount).fill(tokenFactory.build());
} else {
return res(ctx.status(400));
}
diff --git a/frontend/src/utils.test.ts b/frontend/src/utils.test.ts
index 83422b5..9b89c4d 100644
--- a/frontend/src/utils.test.ts
+++ b/frontend/src/utils.test.ts
@@ -1,4 +1,18 @@
-import { customParamSerializer, getTimezoneUTCString, parseSearchTextToQueryParams, getTimeInTimezone } from "./utils";
+import {
+ customParamSerializer,
+ getTimezoneUTCString,
+ parseSearchTextToQueryParams,
+ getTimeInTimezone,
+ formatDistanceToNow,
+} from "./utils";
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
describe("parseSearchTextToQueryParams tests", () => {
it('should modify search params from "label:value" to "label=value"', () => {
@@ -60,3 +74,17 @@ describe("getTimeInTimezone", () => {
});
});
});
+
+describe("formatDistanceToNow", () => {
+ const date = new Date("Fri Apr 21 2023 12:00:00 GMT+0100 (GMT)");
+ [
+ ["2023-04-21T10:30:00.000Z", "30 minutes ago"],
+ ["2023-04-21T11:15:00.000Z", "in 15 minutes"],
+ ].forEach(([time, expected]) => {
+ it(`returns ${expected} time distance for ${time}`, () => {
+ vi.setSystemTime(date);
+ const result = formatDistanceToNow(time);
+ expect(result).toBe(expected);
+ });
+ });
+});
Follow ups