0.1.3 • Published 7 months ago
headless-formik-helper v0.1.3
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, };