Enterprise-CMCS/macpro-mako

View on GitHub
react-app/src/components/ActionForm/index.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
A
94%
import { ReactNode, useMemo } from "react";
import {
  Banner,
  Button,
  UserPrompt,
  SimplePageContainer,
  BreadCrumbs,
  Form,
  LoadingSpinner,
  SectionCard,
  FormField,
  banner,
  userPrompt,
  FAQFooter,
  PreSubmissionMessage,
  optionCrumbsFromPath,
  ActionFormDescription,
  RequiredFieldDescription,
  RequiredIndicator,
} from "@/components";
import { DefaultValues, FieldPath, useForm, UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
import { getFormOrigin } from "@/utils";
import { CheckDocumentFunction, documentPoller } from "@/utils/Poller/documentPoller";
import { API } from "aws-amplify";
import { Authority, CognitoUserAttributes } from "shared-types";
import { ActionFormAttachments, AttachmentsOptions } from "./ActionFormAttachments";
import { getAttachments } from "./actionForm.utilities";
import { isStateUser } from "shared-utils";
import { useGetUser } from "@/api";
import { AdditionalInformation } from "./AdditionalInformation";
import { useMutation } from "@tanstack/react-query";
import { queryClient } from "@/router";

type EnforceSchemaProps<Shape extends z.ZodRawShape> = z.ZodObject<
  Shape & {
    attachments?: z.ZodObject<{
      [Key in keyof Shape]: z.ZodObject<{
        label: z.ZodDefault<z.ZodString>;
        files: z.ZodTypeAny;
      }>;
    }>;
  },
  "strip",
  z.ZodTypeAny
>;

export type SchemaWithEnforcableProps<Shape extends z.ZodRawShape = z.ZodRawShape> =
  | z.ZodEffects<EnforceSchemaProps<Shape>>
  | EnforceSchemaProps<Shape>;

// Utility type to handle Zod schema with or without a transform
type InferUntransformedSchema<T> = T extends z.ZodEffects<infer U> ? U : T;

type ActionFormProps<Schema extends SchemaWithEnforcableProps> = {
  schema: Schema;
  defaultValues?: DefaultValues<z.infer<InferUntransformedSchema<Schema>>>;
  title: string;
  fields: (form: UseFormReturn<z.infer<InferUntransformedSchema<Schema>>>) => ReactNode;
  bannerPostSubmission?: Omit<Banner, "pathnameToDisplayOn">;
  promptPreSubmission?: Omit<UserPrompt, "onAccept">;
  promptOnLeavingForm?: Omit<UserPrompt, "onAccept">;
  attachments?: AttachmentsOptions;
  additionalInformation?:
    | {
        required: boolean;
        title: string;
        label: string;
      }
    | false;
  documentPollerArgs: {
    property: (keyof z.TypeOf<Schema> & string) | ((values: z.TypeOf<Schema>) => string);
    documentChecker: CheckDocumentFunction;
  };
  conditionsDeterminingUserAccess?: ((user: CognitoUserAttributes | null) => boolean)[];
  breadcrumbText: string;
  formDescription?: string;
  preSubmissionMessage?: string;
  showPreSubmissionMessage?: boolean;
  areFieldsRequired?: boolean;
};

export const ActionForm = <Schema extends SchemaWithEnforcableProps>({
  schema,
  defaultValues,
  title,
  fields: Fields,
  bannerPostSubmission = {
    header: "Package submitted",
    body: "Your submission has been received.",
    variant: "success",
  },
  promptOnLeavingForm = {
    header: "Stop form submission?",
    body: "All information you've entered on this form will be lost if you leave this page.",
    acceptButtonText: "Yes, leave form",
    cancelButtonText: "Return to form",
    areButtonsReversed: true,
  },
  promptPreSubmission,
  documentPollerArgs,
  attachments,
  conditionsDeterminingUserAccess = [isStateUser],
  breadcrumbText,
  formDescription = `Once you submit this form, a confirmation email is sent to you and to CMS.
      CMS will use this content to review your package, and you will not be able
      to edit this form. If CMS needs any additional information, they will
      follow up by email.`,
  preSubmissionMessage,
  additionalInformation = {
    required: false,
    label: "Add anything else you would like to share with CMS.",
    title: "Additional Information",
  },
  showPreSubmissionMessage = true,
  areFieldsRequired = true,
}: ActionFormProps<Schema>) => {
  const { id, authority } = useParams<{
    id: string;
    authority: Authority;
    type: string;
  }>();
  const { pathname } = useLocation();

  const navigate = useNavigate();
  const { data: userObj, isLoading: isUserLoading } = useGetUser();

  const breadcrumbs = optionCrumbsFromPath(pathname, authority, id);

  const form = useForm<z.TypeOf<Schema>>({
    resolver: zodResolver(schema),
    mode: "onChange",
    defaultValues: {
      ...defaultValues,
    },
  });

  const { mutateAsync } = useMutation({
    mutationFn: (formData: z.TypeOf<Schema>) =>
      API.post("os", "/submit", {
        body: formData,
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["record"] });
    },
  });

  const onSubmit = form.handleSubmit(async (formData) => {
    try {
      try {
        await mutateAsync(formData);
      } catch (error) {
        throw Error(`Error submitting form: ${error.message}`);
      }

      const { documentChecker, property } = documentPollerArgs;

      const documentPollerId =
        typeof property === "function" ? property(formData) : formData[property];

      try {
        const poller = documentPoller(documentPollerId, documentChecker);
        await poller.startPollingData();
      } catch (error) {
        throw Error(error.error);
      }

      const formOrigins = getFormOrigin({ authority, id });

      navigate(formOrigins);

      // artificially delaying allows the banner to be displayed after navigation
      setTimeout(() => {
        banner({
          ...bannerPostSubmission,
          pathnameToDisplayOn: formOrigins.pathname,
        });
      }, 50);
    } catch (error) {
      console.error(error);
      banner({
        header: "An unexpected error has occurred:",
        body: error instanceof Error ? error.message : String(error),
        variant: "destructive",
        pathnameToDisplayOn: window.location.pathname,
      });
      window.scrollTo(0, 0);
    }
  });

  const attachmentsFromSchema = useMemo(() => getAttachments(schema), [schema]);

  if (isUserLoading === true) {
    return <LoadingSpinner />;
  }

  const doesUserHaveAccessToForm = conditionsDeterminingUserAccess.some((condition) =>
    condition(userObj?.user),
  );

  if (!userObj || doesUserHaveAccessToForm === false) {
    return <Navigate to="/" replace />;
  }

  return (
    <SimplePageContainer>
      <BreadCrumbs
        options={[
          ...breadcrumbs,
          {
            to: pathname,
            displayText: breadcrumbText,
            order: breadcrumbs.length,
          },
        ]}
      />
      {form.formState.isSubmitting && <LoadingSpinner />}
      <Form {...form}>
        <form onSubmit={onSubmit} className="my-6 space-y-8 mx-auto justify-center flex flex-col">
          <SectionCard testId="detail-section" title={title}>
            <div>
              {areFieldsRequired && <RequiredFieldDescription />}
              <ActionFormDescription boldReminder={areFieldsRequired}>
                {formDescription}
              </ActionFormDescription>
            </div>
            <Fields {...form} />
          </SectionCard>
          {attachmentsFromSchema.length > 0 && (
            <ActionFormAttachments attachmentsFromSchema={attachmentsFromSchema} {...attachments} />
          )}
          {additionalInformation && (
            <SectionCard
              testId="additional-info"
              title={
                <>
                  {additionalInformation.title}{" "}
                  {additionalInformation.required && <RequiredIndicator />}
                </>
              }
            >
              <FormField
                control={form.control}
                name={"additionalInformation" as FieldPath<z.TypeOf<Schema>>}
                render={({ field }) => (
                  <AdditionalInformation label={additionalInformation.label} field={field} />
                )}
              />
            </SectionCard>
          )}
          {showPreSubmissionMessage && (
            <PreSubmissionMessage
              hasProgressLossReminder={areFieldsRequired}
              preSubmissionMessage={preSubmissionMessage}
            />
          )}
          <section className="flex justify-end gap-2 p-4 ml-auto">
            <Button
              className="px-12"
              type={promptPreSubmission ? "button" : "submit"}
              onClick={
                promptPreSubmission
                  ? () => userPrompt({ ...promptPreSubmission, onAccept: onSubmit })
                  : undefined
              }
              disabled={form.formState.isValid === false}
              data-testid="submit-action-form"
            >
              Submit
            </Button>
            <Button
              className="px-12"
              onClick={() =>
                userPrompt({
                  ...promptOnLeavingForm,
                  onAccept: () => {
                    const origin = getFormOrigin({ id, authority });
                    navigate(origin);
                  },
                })
              }
              variant="outline"
              type="reset"
              data-testid="cancel-action-form"
            >
              Cancel
            </Button>
          </section>
        </form>
      </Form>
      <FAQFooter />
    </SimplePageContainer>
  );
};