Skip to content

patternhelloworld/headless-formik-helper

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Headless-Formik-Helper

A Clean and Useful Helper Hook for useFormik

Table of Contents

Features

  • Show headless Formik examples using useFormik combined with Yup
  • Eager validation for all inputs
  • Functions for updating key-value pairs
  • Normalization of Formik values before sending them to the server
  • ...More features will be added over time

Usage

  • Registered in NPM
npm install headless-formik-helper

Sample Codes

  • Check comments in the sample code below.
    •   "use client";
      
        import React, {useContext, useEffect, useState} from "react";
        import {FormikErrors, FormikHelpers, FormikProps, useFormik} from "formik";
        import styles from "./productManagement.module.scss";
        import {Button, Text} from "@mantine/core";
      
        import {usePathname, useRouter, useSearchParams} from "next/navigation";
      
        import {
            ProductType,
            CreateProductInput,
            UpdateProductInput,
            useCreateProductMutation,
            useFindProductLazyQuery,
            useRemoveProductMutation,
            useUpdateProductMutation,
        } from "@/generated/graphql";
      
        import MemoizedTextInput from "@/components/common/form-input/MemoizedTextInput";
        import MemoizedSelect from "@/components/common/form-input/MemoizedSelect";
      
        import {GlobalToastContext} from "@/providers/GlobalToastProvider";
        import {UI_COMMON_VALUES} from "@/util/value-util";
        import {
            ExtendedCreateProductInput,
            ExtendedUpdateProductInput,
            PRODUCT_CREATE_INITIAL_VALUES,
            PRODUCT_UPDATE_INITIAL_VALUES,
            PRODUCT_VALIDATION_SCHEMA,
        } from "@/components/product/schema/crud-schema";
      
        import {useRecoilState} from "recoil";
        import {globalLoadingState} from "@/recoil/common";
        import {useIsFirstRender} from "@/hooks/useIsFirstRender";
        
        // 0. Import : headless-formik-helper
        import {useHeadlessFormikHelper} from "headless-formik-helper";
        import {CreateOrUpdateMode} from "headless-formik-helper/dist/types";
        
        const ProductManagement = ({ PK_NAME }: { PK_NAME: string }) => {
            const searchParams = useSearchParams();
            const idForUpdate: number | null = Number(searchParams.get(PK_NAME));
          
            const isFirstRender = useIsFirstRender();
            const router = useRouter();
        
            const { sendErrorMsgToGlobalToast, sendSuccessMsgToGlobalToast } = useContext(GlobalToastContext);
            const [globalLoading, setGlobalLoading] = useRecoilState(globalLoadingState);
          
            const [
                fetchProduct,
                {
                data: fetchProductData,
                loading: fetchProductLoading,
                error: fetchProductError,
                },
            ] = useFindProductLazyQuery();
      
            // 1. useFormik    
            const formik: FormikProps<
                ExtendedCreateProductInput | ExtendedUpdateProductInput
              >   = useFormik<ExtendedCreateProductInput | ExtendedUpdateProductInput>({
                initialValues: {
                ...(idForUpdate
                ? PRODUCT_UPDATE_INITIAL_VALUES
                : PRODUCT_CREATE_INITIAL_VALUES),
                ...fetchProductData?.product
                },
                validationSchema: PRODUCT_VALIDATION_SCHEMA,
                validateOnMount: false,
                validateOnChange: true,
                validateOnBlur: true,
                enableReinitialize: true
            });
      
            // 2. **useHeadlessFormikHelper**
            const {
                formikValuesChanged,
                onKeyValueChangeByEventMemoized,
                onKeyValueChangeByNameValueMemoized,
                normalizeFormikValues,
                } = useHeadlessFormikHelper({
                    formik: formik,
                    eagerValidationInitialOptions: {
                        CREATE_OR_UPDATE: idForUpdate ? CreateOrUpdateMode.UPDATE : CreateOrUpdateMode.CREATE,
                        afterMileSeconds: 0,
                        keyNameToCheckFetchedForUpdate : "id"
                    }
            });
      
      
        const isSubmitDisabled = formik === undefined ? false : !(formik.isValid && formik.dirty);
      
        const [
            createProduct,
            {
            data: createProductData,
            loading: createProductLoading,
            error: createProductError,
        },
        ] = useCreateProductMutation();
        const [
            updateProduct,
            {
            data: updateProductData,
            loading: updateProductLoading,
            error: updateProductError,
        },
        ] = useUpdateProductMutation();
        const [
            removeProduct,
            {
            data: removeProductData,
            loading: removeProductLoading,
            error: removeProductError,
        },
        ] = useRemoveProductMutation();
      
        const createOrUpdateProduct = () => {
            if (formik.values.productType === UI_COMMON_VALUES.SELECT_OPTION_EMPTY) {
                sendErrorMsgToGlobalToast("Product type is required.", true);
                return;
            }
          
            // 3. normalizeFormikValues
            if (!idForUpdate) {
              createProduct({
                variables: {
                  createProductInput: normalizeFormikValues<CreateProductInput>(formik.values),
                },
                onCompleted(data) {
                  sendSuccessMsgToGlobalToast("Product created successfully.");
                  redirectToList();
                },
              });
            } else {
              updateProduct({
                variables: {
                  pickProductInput: { id: idForUpdate },
                  updateProductInput: normalizeFormikValues<UpdateProductInput>(formik.values),
                },
                onCompleted(data) {
                  sendSuccessMsgToGlobalToast("Product updated successfully.");
                  router.refresh();
                },
              });
            }
        };
      
        const fetchProductWrapper = () => {
            if (idForUpdate) {
                fetchProduct({
                    variables: {
                    pickProductInput: {
                    id: idForUpdate,
                    },
                },
             });
            }
        };
      
        const redirectToList = () => {
            router.push("/products");
        };
      
        useEffect(() => {
            fetchProductWrapper();
        }, [idForUpdate]);
      
        useEffect(() => {
            if (idForUpdate && fetchProductData && !fetchProductLoading) {
                const product = fetchProductData.product;
                formik.setValues(product);
            }
        }, [fetchProductData]);
      
        return (
            <div className={styles.mainContainer}>
                <div className={styles.titleContainer}>
                <span className={styles.title}>
                  Product {!idForUpdate ? "Creation" : "Update"}
                </span>
            </div>
      
              <form className={styles.form}>
                <div className={styles.field}>
                  <span>
                    Product Name <span className="required-marker">*</span>
                  </span>
                  <MemoizedTextInput
                    name="name"
                    value={formik.values.name || ""}
                    error={formik.errors.name}
                    touched={formik.touched.name}
                    placeholder="Enter product name"
                    onChange={onKeyValueChangeByEventMemoized}
                    onBlur={formik.handleBlur}
                  />
                </div>
                <div className={styles.field}>
                  <span>Product Type</span>
                  <MemoizedSelect
                    placeholder="Select product type"
                    data={["Physical", "Digital"]}
                    value={formik.values.productType || ""}
                    onChange={(value) => {
                      onKeyValueChangeByNameValueMemoized({
                        name: "productType",
                        value,
                      });
                    }}
                    error={formik.errors.productType}
                    touched={formik.touched.productType}
                  />
                </div>
              </form>
              {formik.values.productType === ProductType.Physical && (
                <ProductTypePhysical formik={formik} />
              )}
              {formik.values.productType === ProductType.Digital && (
                <ProductTypeDigital formik={formik} />
              )}
              <div className="z-10 flex items-center sticky bottom-0 h-14 w-full bg-white justify-end space-x-2 p-2">
                <Button
                  className="w-28"
                  sx={{
                    color: "black",
                    backgroundColor: "#F6F6F6",
                    "&:hover": {
                      backgroundColor: "#E0E0E0",
                    },
                  }}
                  onClick={() => redirectToList()}
              >
                  Cancel
                </Button>
      
                <Button
                  className="w-28"
                  sx={{
                    backgroundColor: "#26C8B9",
                    "&:hover": {
                      backgroundColor: "#1BAA9A",
                    },
                  }}
                  onClick={() => {
                    createOrUpdateProduct();
                  }}
                  disabled={isSubmitDisabled}
              >
                  {!idForUpdate ? "Create" : "Update"}
                </Button>
              </div>
            </div>
        );
        };
      
        export default React.memo(ProductManagement);
    •   import * as Yup from "yup";
        import {
        ProductType,
        CreateProductInput,
        ProductOutput,
        UpdateProductInput,
        } from "@/generated/graphql";
        import { PRODUCT_CATEGORY_TYPE } from "@/components/product/meta/schema";
        import { UI_COMMON_VALUES } from "@/util/value-util";
        import { YUP_EMPTY_VALUE_ERROR_MESSAGE } from "@/util/yup-utils";
      
      /*
      *   The purpose of defining these extended types is to accommodate UI-specific requirements that differ from server constraints.
      *   For instance, "productType" is restricted to "Physical" and "Digital" on the server, but the UI may need to allow an empty value for better user experience.
      */
      
        export type ExtendedCreateProductInput = Omit<
          CreateProductInput,
          "productType">   & {
        productType: ProductType | "-";
        };
      
        export type ExtendedUpdateProductInput = Omit<
            UpdateProductInput,
            "productType"
          >   & {
            productType: ProductType | "-";
        };
      
        export const PRODUCT_VALIDATION_SCHEMA = Yup.object().shape({
         name: Yup.string().required(YUP_EMPTY_VALUE_ERROR_MESSAGE),
         sku: Yup.string()
        .required(YUP_EMPTY_VALUE_ERROR_MESSAGE)
        .matches(/^[a-zA-Z0-9_-]+$/, "SKU must consist of alphanumeric characters, dashes, or underscores."),
          category: Yup.mixed<string>().oneOf(
        Object.values(PRODUCT_CATEGORY_TYPE).map((value) => value.value?.toString()),
        "Invalid product category."
        )
        .required("Product category is required."),
        price: Yup.number()
        .required(YUP_EMPTY_VALUE_ERROR_MESSAGE)
        .min(0, "Price must be a positive number."),
        stock: Yup.number()
        .required(YUP_EMPTY_VALUE_ERROR_MESSAGE)
        .min(0, "Stock must be a non-negative number."),
        productType: Yup.mixed<ProductType>()
        .oneOf(Object.values(ProductType) as ProductType[], "Invalid product type.")
        .required(YUP_EMPTY_VALUE_ERROR_MESSAGE),
        description: Yup.string().nullable(),
        createdAt: Yup.date().nullable(),
        updatedAt: Yup.date().nullable(),
        });
      
        export const PRODUCT_CREATE_INITIAL_VALUES = {
            name: "",
            sku: "",
            category: UI_COMMON_VALUES.SELECT_OPTION_EMPTY, // Example: "Electronics"
            price: 0, // Example: 100
            stock: 0, // Example: 50
            productType: UI_COMMON_VALUES.SELECT_OPTION_EMPTY, // "Physical" or "Digital"
            description: "", // Example: "This is a sample product description."
            createdAt: undefined,
            updatedAt: undefined,
        };
      
        export const PRODUCT_UPDATE_INITIAL_VALUES = {
            name: "",
            sku: "",
            category: UI_COMMON_VALUES.SELECT_OPTION_EMPTY,
            price: 0,
            stock: 0,
            productType: UI_COMMON_VALUES.SELECT_OPTION_EMPTY,
            description: "",
            createdAt: undefined,
            updatedAt: undefined,
        };

APIs

The headless-formik-helper library provides the following utilities to enhance the functionality of Formik forms:

1. onKeyValueChangeByNameValue

Updates a specific field in Formik using its name and a new value.

Signature:

({
  name,
  value,
}: {
  name: keyof T & string;
  value: any;
}) => void;

2. onKeyValueChangeByNameIndexFieldValueMemoized

Handles updates for complex structures like arrays or objects with nested fields. Supports actions like adding or removing items.

Signature:

({
  name,
  index,
  field,
  value,
  action,
  newItem,
}: {
  name: keyof T & string;
  index?: number;
  field?: string;
  value?: any;
  action?: 'add' | 'remove';
  newItem?: any;
}) => void;

3. normalizeFormikValues

Normalizes Formik values before sending them to the server, ensuring compliance with backend requirements.

Signature:

<T extends Record<string, any>>(obj: T) => T;

4. onKeyValueChangeByNameIndexFieldTouchedMemoized

Marks a specific field in a nested structure as "touched" based on its name, index, and field name.

Signature:

({
  name,
  index,
  field,
}: {
  name: keyof T & string;
  index: number;
  field: string;
}) => void;

5. onKeyValueChangeByEvent

Handles changes in Formik fields triggered by standard React change events (e.g., <input>).

Signature:

(e: React.ChangeEvent<HTMLInputElement>) => void;

6. onKeyValueChangeByNameValueMemoized

A memoized version of onKeyValueChangeByNameValue for optimizing updates to Formik fields.

Signature:

({
  name,
  value,
}: KeyValueChangeByNameValueMemoized<T>) => void;

7. formikValuesChanged

Indicates whether Formik values have changed from their initial state.

Signature:

T | boolean;

8. onKeyValueChangeByEventMemoized

A memoized version of onKeyValueChangeByEvent for efficiently handling React change events.

Signature:

(e: React.ChangeEvent<HTMLInputElement>) => void;