ws-nextjs-app/pages/[service]/send/[id]/FormContext/index.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import React, {
  createContext,
  FormEvent,
  PropsWithChildren,
  useContext,
  useState,
} from 'react';
import { v4 as uuid } from 'uuid';

import { useRouter } from 'next/router';
import { OK } from '#app/lib/statusCodes.const';
import getEnvironment from '#app/routes/utils/getEnvironment';
import {
  Field,
  FieldData,
  FileData,
  FormScreen,
  OnChangeHandler,
  OnChangeInputName,
  OnChangeInputValue,
  OnFocusOutHandler,
  ValidationError,
} from '../types';
import UGCSendError from '../UGCSendError';
import validateFunctions from './utils/validateFunctions';
import getValidationErrors from './utils/getValidationErrors';

type SubmissionError = {
  message: string;
  code?: string;
  status: number;
  isRecoverable?: boolean;
} | null;

export type ContextProps = {
  formState: Record<OnChangeInputName, FieldData>;
  handleChange: OnChangeHandler;
  handleFocusOut: OnFocusOutHandler;
  handleSubmit: (event: FormEvent) => Promise<void>;
  submissionError?: SubmissionError;
  submitted: boolean;
  attemptedSubmitCount: number;
  validationErrors: ValidationError[] | [];
  progress: string;
  screen: FormScreen;
  submissionID: string | null;
};

export const FormContext = createContext({} as ContextProps);

const getInitialFormState = (
  fields: Field[],
): Record<OnChangeInputName, FieldData> =>
  fields?.reduce(
    (acc, field) => ({
      ...acc,
      [field.id]: {
        ...(field.validation && field.validation),
        isValid: true,
        required: field.validation.mandatory ?? false,
        wordLimit: field.validation.wordLimit ?? undefined,
        value: field.htmlType === 'file' ? [] : '',
        htmlType: field.htmlType,
        messageCode: null,
        wasInvalid: false,
      },
    }),
    {},
  );

const validateFormStateOnSubmit = (
  state: Record<OnChangeInputName, FieldData>,
) => {
  const formEntries = new Map(Object.entries(state));

  formEntries.forEach((data, key, map) => {
    const validateFunction = validateFunctions[data.htmlType];
    const validatedData = validateFunction ? validateFunction(data) : data;
    map.set(key, validatedData);
  });

  return Object.fromEntries(formEntries);
};

export const FormContextProvider = ({
  initialScreen = 'form',
  fields,
  children,
}: PropsWithChildren<{ initialScreen?: FormScreen; fields: Field[] }>) => {
  const {
    query: { id },
    asPath,
  } = useRouter();

  const [formState, setFormState] = useState(getInitialFormState(fields));
  const [submitted, setSubmitted] = useState(false);
  const [progress, setProgress] = useState('0');
  const [screen, setScreen] = useState<FormScreen>(initialScreen);
  const [submissionError, setSubmissionError] = useState<SubmissionError>(null);
  const [attemptedSubmitCount, setAttemptedSubmitCount] = useState(0);
  const [validationErrors, setValidationErrors] = useState<ValidationError[]>(
    [],
  );
  const [submissionID, setSubmissionID] = useState(null);

  const handleChange = (name: OnChangeInputName, value: OnChangeInputValue) => {
    const prevState = formState[name];
    const currState = { ...prevState, value };
    let validatedData = currState;

    if (currState.htmlType === 'file') {
      const validateFunction = validateFunctions.file;
      validatedData = validateFunction
        ? validateFunction(currState)
        : currState;
    }
    const updatedState = { [name]: { ...validatedData } };
    const newFormState = { ...formState, ...updatedState };
    setFormState(newFormState);

    if (currState.htmlType === 'file') {
      const validationErrorsList = getValidationErrors(newFormState);
      setValidationErrors(validationErrorsList);
    }
  };

  const handleFocusOut = (name: OnChangeInputName) => {
    const currState = formState[name];
    const validateFunction = validateFunctions[currState.htmlType];
    const validatedData = validateFunction
      ? validateFunction(currState)
      : currState;
    const updatedState = { [name]: { ...validatedData } };
    const newFormState = { ...formState, ...updatedState };

    const validationErrorsList = getValidationErrors(newFormState);
    setValidationErrors(validationErrorsList);

    setFormState(newFormState);
  };

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setAttemptedSubmitCount(prevCount => prevCount + 1);
    // Reset error state
    setSubmissionError(null);
    const validatedFormData = validateFormStateOnSubmit(formState);
    setFormState(validatedFormData);

    const validationErrorsList = getValidationErrors(validatedFormData);
    if (validationErrorsList.length > 0) {
      setValidationErrors(validationErrorsList);
      return;
    }

    setSubmitted(true);

    const formData = new FormData();

    Object.entries(formState).forEach(([key, item]) => {
      const fieldValue = item.value;
      const isFileHtmlType = item.htmlType === 'file';

      if (fieldValue === '') return;
      if (isFileHtmlType) {
        const fileList = fieldValue as FileData[];

        fileList.forEach(({ file }: FileData) => {
          formData.append(key, file);
        });
        return;
      }
      if (typeof fieldValue === 'boolean') {
        if (fieldValue) formData.append(key, 'true');
        return;
      }
      formData.append(key, fieldValue as string);
    });
    try {
      const environment = getEnvironment(asPath);
      const domain = `https://www.${environment === 'test' ? 'test.' : ''}bbc.com`;
      const url = `${domain}/ugc/send/${id}?said=${uuid()}`;

      const req = new XMLHttpRequest();
      req.responseType = 'json';
      req.open('POST', url, true);

      req.upload.onloadstart = () => {
        setScreen('uploading');
      };

      req.upload.onprogress = e => {
        setProgress(((e.loaded / e.total) * 100).toFixed(0));
      };
      req.onreadystatechange = () => {
        if (req.readyState === XMLHttpRequest.DONE) {
          setSubmitted(false);
          if (req.status === OK) {
            setSubmissionID(req.response.submissionId);
            setTimeout(() => {
              setScreen('success');
            }, 3000);
          }
          if (req.status !== OK) {
            const { message, code, status, isRecoverable } = new UGCSendError(
              req,
            );

            setSubmissionError({
              message,
              code,
              status,
              isRecoverable,
            });
            setTimeout(() => {
              setScreen('error');
            }, 3000);
          }
        }
      };
      req.send(formData);
    } catch (error) {
      const { message, status } = error as UGCSendError;

      setSubmissionError({ message, status });
      setTimeout(() => {
        setScreen('error');
      }, 3000);
    }
  };

  return (
    <FormContext.Provider
      value={{
        formState,
        handleChange,
        handleFocusOut,
        handleSubmit,
        submissionError,
        submitted,
        progress,
        attemptedSubmitCount,
        validationErrors,
        screen,
        submissionID,
      }}
    >
      {children}
    </FormContext.Provider>
  );
};

export function useFormContext() {
  return useContext(FormContext);
}