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