Peter Makowski has proposed merging 
~petermakowski/maas-site-manager:remove-region-dialog-MAASENG-1559 into 
maas-site-manager:main.

Commit message:
add useSiteQueryData hook
- rename hooks/api to hooks/react-query


Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/442206

https://warthogs.atlassian.net/browse/MAASENG-1559

QA Steps
go to /sites
select a single region
go to another page
click "Remove region"
verify that the name of the region is required as the confirmation text
submit the form without entering the confirmation text
verify the error message has been displayed
enter a valid confirmation text
verify the error message has been removed and form submitted
-- 
Your team MAAS Committers is requested to review the proposed merge of 
~petermakowski/maas-site-manager:remove-region-dialog-MAASENG-1559 into 
maas-site-manager:main.
diff --git a/frontend/src/components/EnrollmentActions/EnrollmentActions.test.tsx b/frontend/src/components/EnrollmentActions/EnrollmentActions.test.tsx
index 1ffa6dd..3047677 100644
--- a/frontend/src/components/EnrollmentActions/EnrollmentActions.test.tsx
+++ b/frontend/src/components/EnrollmentActions/EnrollmentActions.test.tsx
@@ -1,6 +1,6 @@
 import EnrollmentActions from "./EnrollmentActions";
 
-import type * as apiHooks from "@/hooks/api";
+import type * as apiHooks from "@/hooks/react-query";
 import { render, screen, within } from "@/test-utils";
 
 const enrollmentRequestsMutationMock = vi.fn();
@@ -13,7 +13,7 @@ it("displays enrollment action buttons", () => {
 });
 
 it("can display an error message on request error", () => {
-  vi.mock("@/hooks/api", async (importOriginal) => {
+  vi.mock("@/hooks/react-query", async (importOriginal) => {
     const original: typeof apiHooks = await importOriginal();
     return {
       ...original,
diff --git a/frontend/src/components/EnrollmentActions/EnrollmentActions.tsx b/frontend/src/components/EnrollmentActions/EnrollmentActions.tsx
index 812ddad..ed4f073 100644
--- a/frontend/src/components/EnrollmentActions/EnrollmentActions.tsx
+++ b/frontend/src/components/EnrollmentActions/EnrollmentActions.tsx
@@ -3,7 +3,7 @@ import { Button, Notification } from "@canonical/react-components";
 import EnrollmentNotification from "./EnrollmentNotification";
 
 import { useAppContext } from "@/context";
-import { useEnrollmentRequestsMutation } from "@/hooks/api";
+import { useEnrollmentRequestsMutation } from "@/hooks/react-query";
 
 const EnrollmentActions: React.FC = () => {
   const { rowSelection, setRowSelection } = useAppContext();
diff --git a/frontend/src/components/LoginForm/LoginForm.tsx b/frontend/src/components/LoginForm/LoginForm.tsx
index 1ec8e96..7e69dae 100644
--- a/frontend/src/components/LoginForm/LoginForm.tsx
+++ b/frontend/src/components/LoginForm/LoginForm.tsx
@@ -70,6 +70,7 @@ const LoginForm = () => {
               <Formik<LoginFormValues>
                 initialValues={initialValues}
                 onSubmit={handleSubmit}
+                validateOnBlur={false}
                 validationSchema={LoginFormSchema}
               >
                 {({ isSubmitting, errors, touched, isValid, dirty }) => (
diff --git a/frontend/src/components/NoRegions/NoRegions.tsx b/frontend/src/components/NoRegions/NoRegions.tsx
index 21fa552..b06e77e 100644
--- a/frontend/src/components/NoRegions/NoRegions.tsx
+++ b/frontend/src/components/NoRegions/NoRegions.tsx
@@ -1,7 +1,7 @@
 import docsUrls from "@/base/docsUrls";
 import ExternalLink from "@/components/ExternalLink";
 import TableCaption from "@/components/TableCaption";
-import { useRequestsCountQuery } from "@/hooks/api";
+import { useRequestsCountQuery } from "@/hooks/react-query";
 import { Link } from "@/router";
 
 const NoRegions = () => {
diff --git a/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx b/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
index 5b1c6ef..80b268d 100644
--- a/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
+++ b/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
@@ -11,20 +11,22 @@ vi.mock("@/context", () => ({
   }),
 }));
 
-it("if the correct phrase has been entered the 'Remove' button becomes enabled.", async () => {
+it("submit button should not be disabled when something has been typed", async () => {
   render(<RemoveRegions />);
+  const errorMessage = /Confirmation string is not correct/i;
   expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
-  await userEvent.type(screen.getByRole("textbox"), "remove 2 regions");
-  expect(screen.queryByText(/Confirmation string is not correct/i)).not.toBeInTheDocument();
+  await userEvent.type(screen.getByRole("textbox"), "invalid text");
+  expect(screen.queryByText(errorMessage)).not.toBeInTheDocument();
   expect(screen.getByRole("button", { name: /Remove/i })).toBeEnabled();
 });
 
-it("if the confirmation string is not correct and the user unfocuses the input field a error state is shown.", async () => {
+it("validation error is shown after user attempts submission", async () => {
   render(<RemoveRegions />);
-  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+  const errorMessage = /Confirmation string is not correct/i;
   await userEvent.type(screen.getByRole("textbox"), "incorrect string{tab}");
-  expect(screen.getByText(/Confirmation string is not correct/i)).toBeInTheDocument();
-  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+  expect(screen.queryByText(errorMessage)).not.toBeInTheDocument();
+  await userEvent.click(screen.getByRole("button", { name: /Remove/i }));
+  expect(screen.getByText(errorMessage)).toBeInTheDocument();
 });
 
 it("does not display error message on blur if the value has not chagned", async () => {
@@ -34,3 +36,14 @@ it("does not display error message on blur if the value has not chagned", async 
   expect(screen.queryByText(/Confirmation string is not correct/i)).not.toBeInTheDocument();
   expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
 });
+
+it("validation error is hidden on change if the user already attempted submission", async () => {
+  render(<RemoveRegions />);
+  const errorMessage = /Confirmation string is not correct/i;
+  await userEvent.type(screen.getByRole("textbox"), "incorrect string");
+  await userEvent.click(screen.getByRole("button", { name: /Remove/i }));
+  expect(screen.getByText(errorMessage)).toBeInTheDocument();
+  await userEvent.clear(screen.getByRole("textbox"));
+  await userEvent.type(screen.getByRole("textbox"), "remove 2 regions");
+  expect(screen.queryByText(errorMessage)).not.toBeInTheDocument();
+});
diff --git a/frontend/src/components/RemoveRegions/RemoveRegions.tsx b/frontend/src/components/RemoveRegions/RemoveRegions.tsx
index 1f6f32a..a325138 100644
--- a/frontend/src/components/RemoveRegions/RemoveRegions.tsx
+++ b/frontend/src/components/RemoveRegions/RemoveRegions.tsx
@@ -1,11 +1,13 @@
 import { useEffect } from "react";
 
 import { Button, Icon, Input, useId } from "@canonical/react-components";
+import type { FormikHelpers } from "formik";
 import { Field, Form, Formik } from "formik";
 import pluralize from "pluralize";
 import * as Yup from "yup";
 
 import { useAppContext } from "@/context";
+import { useSiteQueryData } from "@/hooks/react-query";
 
 const initialValues = {
   confirmText: "",
@@ -34,17 +36,20 @@ const createHandleValidate =
 const RemoveRegions = () => {
   const { rowSelection } = useAppContext();
   const { setSidebar } = useAppContext();
-  const handleDeleteSites = () => {
-    // TODO: integrate with delete sites endpoint
-    setSidebar(null);
-  };
   const regionsCount = rowSelection && Object.keys(rowSelection).length;
   const id = useId();
   const confirmTextId = `confirm-text-${id}`;
   const headingId = `heading-${id}`;
-  const regionsCountText = pluralize("regions", regionsCount || 0, !!regionsCount);
+  const site = useSiteQueryData(Object.keys(rowSelection)?.[0]);
+  const regionName = site?.name;
+  const regionsCountText = regionsCount === 1 ? regionName : pluralize("regions", regionsCount || 0, !!regionsCount);
   const expectedConfirmTextValue = `remove ${regionsCountText}`;
-  const handleSubmit = () => {
+  const handleSubmit = (
+    _values: RemoveRegionsFormValues,
+    { setSubmitting }: FormikHelpers<RemoveRegionsFormValues>,
+  ) => {
+    setSubmitting(false);
+    setSidebar(null);
     // TODO: integrate with delete regions endpoint
   };
 
@@ -60,9 +65,8 @@ const RemoveRegions = () => {
       initialValues={initialValues}
       onSubmit={handleSubmit}
       validate={createHandleValidate({ expectedConfirmTextValue })}
-      validateOnBlur={false}
     >
-      {({ isSubmitting, errors, touched, isValid, dirty }) => (
+      {({ isSubmitting, errors, touched, dirty, submitCount }) => (
         <Form aria-labelledby={headingId} className="tokens-create" noValidate>
           <div className="tokens-create">
             <h3 className="tokens-create__heading p-heading--4" id={headingId}>
@@ -78,7 +82,7 @@ const RemoveRegions = () => {
             <Field
               aria-labelledby={confirmTextId}
               as={Input}
-              error={touched.confirmText && errors.confirmText}
+              error={submitCount > 0 && touched.confirmText && errors.confirmText}
               name="confirmText"
               placeholder={`remove ${regionsCountText}`}
               type="text"
@@ -86,12 +90,8 @@ const RemoveRegions = () => {
             <Button appearance="base" onClick={() => setSidebar(null)} type="button">
               Cancel
             </Button>
-            <Button
-              appearance="negative"
-              disabled={!dirty || !isValid || isSubmitting}
-              onClick={handleDeleteSites}
-              type="button"
-            >
+            {/* TODO: create Form.SubmitButton formik submit button component */}
+            <Button appearance="negative" disabled={!dirty || isSubmitting} type="submit">
               <Icon light name="delete" /> Remove
             </Button>
           </div>
diff --git a/frontend/src/components/RequestsList/RequestsList.tsx b/frontend/src/components/RequestsList/RequestsList.tsx
index 3f5be3c..c3a3e8c 100644
--- a/frontend/src/components/RequestsList/RequestsList.tsx
+++ b/frontend/src/components/RequestsList/RequestsList.tsx
@@ -5,7 +5,7 @@ import { Col, Row } from "@canonical/react-components";
 import EnrollmentActions from "@/components/EnrollmentActions";
 import RequestsTable from "@/components/RequestsTable";
 import PaginationBar from "@/components/base/PaginationBar";
-import { useRequestsQuery } from "@/hooks/api";
+import { useRequestsQuery } from "@/hooks/react-query";
 import usePagination from "@/hooks/usePagination";
 
 const DEFAULT_PAGE_SIZE = 50;
diff --git a/frontend/src/components/RequestsTable/RequestsTable.tsx b/frontend/src/components/RequestsTable/RequestsTable.tsx
index 000f238..20887c4 100644
--- a/frontend/src/components/RequestsTable/RequestsTable.tsx
+++ b/frontend/src/components/RequestsTable/RequestsTable.tsx
@@ -11,7 +11,7 @@ import SelectAllCheckbox from "@/components/SelectAllCheckbox";
 import TableCaption from "@/components/TableCaption";
 import { isDev } from "@/constants";
 import { useAppContext } from "@/context";
-import type { UseEnrollmentRequestsQueryResult } from "@/hooks/api";
+import type { UseEnrollmentRequestsQueryResult } from "@/hooks/react-query";
 
 export type EnrollmentRequestsColumnDef = ColumnDef<EnrollmentRequest, EnrollmentRequest[keyof EnrollmentRequest]>;
 export type EnrollmentRequestsColumn = Column<EnrollmentRequest, unknown>;
diff --git a/frontend/src/components/SitesList/SitesList.tsx b/frontend/src/components/SitesList/SitesList.tsx
index 39a8c26..959b819 100644
--- a/frontend/src/components/SitesList/SitesList.tsx
+++ b/frontend/src/components/SitesList/SitesList.tsx
@@ -4,7 +4,7 @@ import { Pagination } from "@canonical/react-components";
 
 import SitesTable from "./SitesTable";
 
-import { useSitesQuery } from "@/hooks/api";
+import { useSitesQuery } from "@/hooks/react-query";
 import useDebounce from "@/hooks/useDebouncedValue";
 import { parseSearchTextToQueryParams } from "@/utils";
 
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
index 2d26837..01efda1 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
@@ -17,7 +17,7 @@ import SelectAllCheckbox from "@/components/SelectAllCheckbox";
 import TooltipButton from "@/components/base/TooltipButton/TooltipButton";
 import { isDev } from "@/constants";
 import { useAppContext } from "@/context";
-import type { UseSitesQueryResult } from "@/hooks/api";
+import type { UseSitesQueryResult } from "@/hooks/react-query";
 import { getAllMachines, getCountryName, getTimezoneUTCString, getTimeInTimezone } from "@/utils";
 
 const createAccessor =
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesCount/SitesCount.tsx b/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesCount/SitesCount.tsx
index ed0b8b3..bada309 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesCount/SitesCount.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesCount/SitesCount.tsx
@@ -1,7 +1,7 @@
 import pluralize from "pluralize";
 
 import Placeholder from "@/components/Placeholder";
-import type { UseSitesQueryResult } from "@/hooks/api";
+import type { UseSitesQueryResult } from "@/hooks/react-query";
 
 const SitesCount = ({ data, isLoading }: Pick<UseSitesQueryResult, "data" | "isLoading">) =>
   isLoading ? (
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesTableControls.tsx b/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesTableControls.tsx
index d3b78b1..1ff3107 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesTableControls.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTableControls/SitesTableControls.tsx
@@ -5,7 +5,7 @@ import SitesCount from "./SitesCount";
 
 import type { SitesColumn } from "@/components/SitesList/SitesTable/SitesTable";
 import { useAppContext } from "@/context";
-import type { UseSitesQueryResult } from "@/hooks/api";
+import type { UseSitesQueryResult } from "@/hooks/react-query";
 
 const SitesTableControls = ({
   data,
diff --git a/frontend/src/components/TokensCreate/TokensCreate.test.tsx b/frontend/src/components/TokensCreate/TokensCreate.test.tsx
index 396a777..069cb4a 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.test.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.test.tsx
@@ -4,7 +4,7 @@ import { setupServer } from "msw/node";
 import TokensCreate from "./TokensCreate";
 
 import urls from "@/api/urls";
-import type * as apiHooks from "@/hooks/api";
+import type * as apiHooks from "@/hooks/react-query";
 import { createMockTokensResolver } from "@/mocks/resolvers";
 import { renderWithMemoryRouter, screen, userEvent } from "@/test-utils";
 
@@ -12,7 +12,7 @@ const mockServer = setupServer(rest.post(urls.tokens, createMockTokensResolver()
 
 const tokensMutationMock = vi.fn();
 
-vi.mock("@/hooks/api", async (importOriginal) => {
+vi.mock("@/hooks/react-query", async (importOriginal) => {
   const original: typeof apiHooks = await importOriginal();
   return { ...original, useTokensCreateMutation: () => ({ mutateAsync: tokensMutationMock }) };
 });
diff --git a/frontend/src/components/TokensCreate/TokensCreate.tsx b/frontend/src/components/TokensCreate/TokensCreate.tsx
index b9e415e..5132dfb 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 { useTokensCreateMutation } from "@/hooks/api";
+import { useTokensCreateMutation } from "@/hooks/react-query";
 
 const initialValues = {
   amount: "",
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index 8b93285..3878019 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -10,7 +10,7 @@ import { routesConfig } from "@/base/routesConfig";
 import ExternalLink from "@/components/ExternalLink";
 import PaginationBar from "@/components/base/PaginationBar";
 import { useAppContext } from "@/context";
-import { useDeleteTokensMutation, useTokensQuery } from "@/hooks/api";
+import { useDeleteTokensMutation, useTokensQuery } from "@/hooks/react-query";
 import usePagination from "@/hooks/usePagination";
 import { Link } from "@/router";
 
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
index c30491c..3c4d33a 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
@@ -9,7 +9,7 @@ import SelectAllCheckbox from "@/components/SelectAllCheckbox";
 import CopyButton from "@/components/base/CopyButton";
 import TooltipButton from "@/components/base/TooltipButton";
 import { useAppContext } from "@/context";
-import type { useTokensQueryResult } from "@/hooks/api";
+import type { useTokensQueryResult } from "@/hooks/react-query";
 import { copyToClipboard, formatDistanceToNow, formatUTCDateString } from "@/utils";
 
 const createAccessor =
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index 69387ba..1994585 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -4,8 +4,8 @@ import React, { createContext, useContext, useReducer } from "react";
 import type { AxiosInstance } from "axios";
 import useLocalStorageState from "use-local-storage-state";
 
-import { useLoginMutation } from "@/hooks/api";
-import type { LoginError } from "@/hooks/api";
+import { useLoginMutation } from "@/hooks/react-query";
+import type { LoginError } from "@/hooks/react-query";
 
 type AuthStatus = "initial" | "authenticated" | "unauthorised";
 
diff --git a/frontend/src/hooks/api.test.ts b/frontend/src/hooks/api.test.ts
index 9186e36..cc756a1 100644
--- a/frontend/src/hooks/api.test.ts
+++ b/frontend/src/hooks/api.test.ts
@@ -2,7 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react";
 import { rest } from "msw";
 import { setupServer } from "msw/node";
 
-import { useSitesQuery, useTokensQuery } from "./api";
+import { useSitesQuery, useTokensQuery } from "./react-query";
 
 import urls from "@/api/urls";
 import { siteFactory, tokenFactory } from "@/mocks/factories";
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/react-query.ts
similarity index 92%
rename from frontend/src/hooks/api.ts
rename to frontend/src/hooks/react-query.ts
index 4760bb1..8636f61 100644
--- a/frontend/src/hooks/api.ts
+++ b/frontend/src/hooks/react-query.ts
@@ -24,6 +24,7 @@ import type {
   EnrollmentRequestsQueryResult,
   AccessToken,
   Token,
+  Site,
 } from "@/api/types";
 
 export type UseSitesQueryResult = ReturnType<typeof useSitesQuery>;
@@ -38,6 +39,22 @@ export const useSitesQuery = ({ page, size }: GetSitesQueryParams, queryText?: s
     refetchInterval: defaultRefetchInterval,
   });
 
+// return single site data from query cache
+export const useSiteQueryData = (id: Site["id"]): Site | null => {
+  const queryClient = useQueryClient();
+  // query cache data for all pages
+  // this is to ensure we can request a site that is not on the current page
+  const queryDataList = queryClient.getQueriesData<SitesQueryResult>({
+    queryKey: ["sites"],
+    exact: false,
+    type: "all",
+  });
+  // reduce to a single list
+  const sites = queryDataList.reduce((acc, [_key, data]) => [...acc, ...(data?.items ?? [])], [] as Site[]);
+  const site = sites.find((site: any) => site.id === id);
+  return site || null;
+};
+
 export type useTokensQueryResult = ReturnType<typeof useTokensQuery>;
 export const useTokensQuery = ({ page, size }: GetTokensQueryParams) =>
   useQuery<PostTokensResult>({
-- 
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