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);
+    });
+  });
+});
-- 
Mailing list: https://launchpad.net/~sts-sponsors
Post to     : sts-sponsors@lists.launchpad.net
Unsubscribe : https://launchpad.net/~sts-sponsors
More help   : https://help.launchpad.net/ListHelp

Reply via email to