From 0e5c7ab0df7f4bce78eeb378a8e1b7b08a5e7458 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 4 Apr 2024 14:35:05 -0400 Subject: [PATCH] feat: add formik helper components --- package-lock.json | 83 +++++++++++++- package.json | 4 +- src/renderer/components/form/Field.tsx | 59 ++++++++++ src/renderer/components/form/Repeater.tsx | 49 +++++++++ .../components/form/_example.addressgroup.tsx | 25 +++++ src/renderer/components/form/_example.tsx | 103 ++++++++++++++++++ src/renderer/components/form/index.ts | 3 +- 7 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 src/renderer/components/form/Field.tsx create mode 100644 src/renderer/components/form/Repeater.tsx create mode 100644 src/renderer/components/form/_example.addressgroup.tsx create mode 100644 src/renderer/components/form/_example.tsx diff --git a/package-lock.json b/package-lock.json index 3acd092a..9ff5fc67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "flowbite": "^2.3.0", "flowbite-react": "^0.7.6", "flowbite-typography": "^1.0.3", + "formik": "^2.4.5", "lodash": "^4.17.21", "react": "^18.2.0", "react-content-loader": "^7.0.0", @@ -36,7 +37,8 @@ "simplebar-react": "^3.2.4", "styled-components": "^6.1.8", "uuid": "^9.0.1", - "xterm": "^5.3.0" + "xterm": "^5.3.0", + "yup": "^1.4.0" }, "devDependencies": { "@commitlint/config-conventional": "^19.1.0", @@ -4298,6 +4300,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -5672,6 +5682,30 @@ "url": "https://ko-fi.com/tunnckoCore/commissions" } }, + "node_modules/formik": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", + "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8408,6 +8442,11 @@ "node": ">=10" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8567,6 +8606,11 @@ "react": "^18.2.0" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "node_modules/react-icons": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz", @@ -10309,6 +10353,16 @@ "readable-stream": "3" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -10346,6 +10400,11 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10969,6 +11028,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", diff --git a/package.json b/package.json index 5b2416cc..2f2e04d9 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "flowbite": "^2.3.0", "flowbite-react": "^0.7.6", "flowbite-typography": "^1.0.3", + "formik": "^2.4.5", "lodash": "^4.17.21", "react": "^18.2.0", "react-content-loader": "^7.0.0", @@ -52,7 +53,8 @@ "simplebar-react": "^3.2.4", "styled-components": "^6.1.8", "uuid": "^9.0.1", - "xterm": "^5.3.0" + "xterm": "^5.3.0", + "yup": "^1.4.0" }, "devDependencies": { "@commitlint/config-conventional": "^19.1.0", diff --git a/src/renderer/components/form/Field.tsx b/src/renderer/components/form/Field.tsx new file mode 100644 index 00000000..94c6a8c6 --- /dev/null +++ b/src/renderer/components/form/Field.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useFormikContext, FormikValues } from 'formik'; +import { Label, HelperText } from 'flowbite-react'; + +interface FieldProps { + name: string; + label?: string; + readonly?: boolean; + children: React.ReactElement; +} + +const Field: React.FC = ({ name, label, readonly, children }) => { + const { errors, touched, values, setFieldValue, setFieldTouched }: FormikValues = useFormikContext(); + + const isError: boolean = !!errors[name] && !!touched[name]; + + // Enforce a single child + const child = React.Children.only(children); + + // Styles for the read-only value container + const readOnlyValueStyles = "py-2 px-4 bg-gray-100 text-gray-800 rounded border border-gray-300"; + + if (readonly) { + return ( +
+ {label && } +
+ {values[name] || 'N/A'} {/* Display 'N/A' if the value is falsy */} +
+ {isError && {errors[name]}} +
+ ); + } + + // Enhance the single child component with additional Formik-related props + const enhancedChild = React.cloneElement(child, { + id: name, + name, + onChange: (e: React.ChangeEvent) => { + child.props.onChange?.(e); + setFieldValue(name, e.target.value); + }, + onBlur: (e: React.FocusEvent) => { + child.props.onBlur?.(e); + setFieldTouched(name, true); + }, + color: isError ? 'failure' : 'gray', + }); + + return ( +
+ {label && } + {enhancedChild} + {isError && {errors[name]}} +
+ ); +}; + +export default Field; diff --git a/src/renderer/components/form/Repeater.tsx b/src/renderer/components/form/Repeater.tsx new file mode 100644 index 00000000..c840f224 --- /dev/null +++ b/src/renderer/components/form/Repeater.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { FieldArray, useFormikContext, FormikValues } from 'formik'; +import { Button } from 'flowbite-react'; + +interface RepeaterProps { + name: string; + component: React.ReactElement; + maxNumber?: number; + minNumber?: number; +} + +const Repeater: React.FC = ({ name, component, maxNumber = 5, minNumber = 1 }) => { + const { values }: FormikValues = useFormikContext(); + const groups = values[name] || []; + + return ( + ( +
+ {groups.map((group: any, index: number) => ( +
+ {/* Clone the component for each group and pass in the adjusted name prop */} + {React.cloneElement(component, { name: `${name}[${index}]` })} + + {/* Remove button for each group */} + +
+ ))} + + {/* Add button - shown if maxNumber hasn't been reached */} + {groups.length < maxNumber && ( + + )} +
+ )} + /> + ); +}; + +export default Repeater; diff --git a/src/renderer/components/form/_example.addressgroup.tsx b/src/renderer/components/form/_example.addressgroup.tsx new file mode 100644 index 00000000..12f73b73 --- /dev/null +++ b/src/renderer/components/form/_example.addressgroup.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { TextInput } from 'flowbite-react'; +import Field from './Field'; // Make sure the import path is correct + +interface AddressGroupProps { + name: string; // Base name for the group, used to construct field names +} + +const AddressGroup: React.FC = ({ name }) => { + return ( +
+ + + + + + + + + +
+ ); +}; + +export default AddressGroup; \ No newline at end of file diff --git a/src/renderer/components/form/_example.tsx b/src/renderer/components/form/_example.tsx new file mode 100644 index 00000000..c37ba2e5 --- /dev/null +++ b/src/renderer/components/form/_example.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { Formik, Form } from 'formik'; +import * as Yup from 'yup'; +import { Tab } from 'flowbite-react'; +import Field from './Field'; // Adjust import path +import Repeater from './Repeater'; // Adjust import path +import AddressGroup from './AddressGroup'; // Adjust import path +import { useSubmitUserFormMutation } from './userService'; // Adjust import path +import { Button } from 'flowbite-react'; + +// Define validation schemas for each tab +const userValidationSchema = Yup.object({ + firstName: Yup.string().max(15, 'Must be 15 characters or less').required('Required'), + lastName: Yup.string().max(20, 'Must be 20 characters or less').required('Required'), + email: Yup.string().email('Invalid email address').required('Required'), +}); + +const addressValidationSchema = Yup.array().of( + Yup.object({ + street: Yup.string().required('Required'), + city: Yup.string().required('Required'), + zipCode: Yup.string().required('Required'), + }) +).min(1, 'At least one address is required'); + +// Combine the validation schemas for final form submission validation +const validationSchema = Yup.object({ + ...userValidationSchema.fields, + addresses: addressValidationSchema, +}); + +const UserForm = () => { + const [submitUserForm, { isLoading }] = useSubmitUserFormMutation(); + const [activeTab, setActiveTab] = useState('userInfo'); + + const initialValues = { + userId: '12345', + firstName: '', + lastName: '', + email: '', + addresses: [{ street: '', city: '', zipCode: '' }], + }; + + const handleSubmit = async (values: any) => { + try { + await submitUserForm(values).unwrap(); + } catch (error) { + console.error('Form submission error:', error); + } + }; + + // Function to handle tab changes with validation + const handleTabChange = async (tab: string, validateForm: any) => { + const errors = await validateForm(); + // Check for errors in the current active tab's fields before switching + if (activeTab === 'userInfo' && !errors.firstName && !errors.lastName && !errors.email) { + setActiveTab(tab); + } else if (activeTab === 'addresses' && !errors.addresses) { + setActiveTab(tab); + } + }; + + return ( + + {({ validateForm }) => ( +
+ + + handleTabChange('userInfo', validateForm)}>User Information + handleTabChange('addresses', validateForm)}>Addresses + + + + {activeTab === 'userInfo' && ( +
+ + + + + + + + + +
+ )} + + {activeTab === 'addresses' && ( + + + + )} + + +
+ )} +
+ ); +}; + +export default UserForm; diff --git a/src/renderer/components/form/index.ts b/src/renderer/components/form/index.ts index 693da49f..17697c5a 100644 --- a/src/renderer/components/form/index.ts +++ b/src/renderer/components/form/index.ts @@ -1 +1,2 @@ -export {} \ No newline at end of file +export * from './FormSection'; +export * from './Repeater'; \ No newline at end of file