├── .eslintignore ├── src ├── utils │ ├── index.ts │ └── transformString.ts ├── index.ts ├── components │ ├── FieldHelperText.tsx │ ├── LoadingButton.tsx │ ├── index.ts │ ├── CheckboxInput.tsx │ ├── FieldLabel.tsx │ ├── PasswordInput.tsx │ ├── RatingInput.tsx │ ├── DatePickerInput.tsx │ ├── TagsInput.tsx │ ├── RangeInput.tsx │ ├── SelectInput.tsx │ ├── TextInput.tsx │ ├── FormikForm.tsx │ ├── Collections │ │ └── CollectionTextInput.tsx │ └── FormikFormInput.tsx └── types.ts ├── README.md ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ └── ci.yaml ├── rollup.config.js ├── .eslintrc.js └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | .eslintrc.js -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transformString'; 2 | 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components' 2 | export * from './utils' 3 | 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # formand 2 | Build beautiful and interactive forms with a simple json configuration 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | .DS_Store 6 | *.tgz 7 | lerna-debug.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | /.changelog 12 | .npm/ 13 | dist/ 14 | experiment -------------------------------------------------------------------------------- /src/components/FieldHelperText.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, TypographyProps } from '@mui/material' 2 | 3 | export interface FieldHelperTextProps extends TypographyProps { 4 | helperText: string 5 | } 6 | 7 | export default function FieldHelperText (props: FieldHelperTextProps): JSX.Element { 8 | const { helperText, sx = {} } = props 9 | return ( 10 | 11 | {helperText} 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "strict": true, 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "module": "ESNext", 9 | "declaration": true, 10 | "declarationDir": "types", 11 | "sourceMap": true, 12 | "outDir": "dist", 13 | "moduleResolution": "node", 14 | "allowSyntheticDefaultImports": true, 15 | "emitDeclarationOnly": true, 16 | "types": [ 17 | "react" 18 | ], 19 | } 20 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | name: "Build" 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Use Node.js 16 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "16" 19 | - name: Install dependencies 20 | run: | 21 | npm ci --no-fund --no-audit 22 | - name: Lint 23 | run: | 24 | npm run lint 25 | - name: Build 26 | run: | 27 | npm run build 28 | -------------------------------------------------------------------------------- /src/components/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, CircularProgress } from '@mui/material' 2 | 3 | interface LoadingButtonProps extends ButtonProps { 4 | children: JSX.Element | string 5 | } 6 | 7 | export default function LoadingButton ({ children, ...props }: LoadingButtonProps): JSX.Element { 8 | return ( 9 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { CheckboxInputProps, default as CheckboxInput } from './CheckboxInput' 2 | export { DatePickerInputProps, default as DatePickerInput } from './DatePickerInput' 3 | export { default as FieldHelperText, FieldHelperTextProps } from './FieldHelperText' 4 | export { default as FieldLabel, FieldLabelProps } from './FieldLabel' 5 | export { default as PasswordInput } from './PasswordInput' 6 | export { default as RangeInput, RangeInputProps } from './RangeInput' 7 | export { default as RatingInput, RatingInputProps } from './RatingInput' 8 | export { default as SelectInput, SelectInputProps } from './SelectInput' 9 | export { default as TagsInput, TagsInputProps } from './TagsInput' 10 | export { default as TextInput, TextInputProps } from './TextInput' 11 | 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import typescript from '@rollup/plugin-typescript' 4 | import dts from 'rollup-plugin-dts' 5 | import packageJson from './package.json' 6 | 7 | export default [ 8 | { 9 | input: 'src/index.ts', 10 | output: [ 11 | { 12 | file: packageJson.main, 13 | format: 'cjs', 14 | sourcemap: true 15 | }, 16 | { 17 | file: packageJson.module, 18 | format: 'esm', 19 | sourcemap: true 20 | } 21 | ], 22 | plugins: [ 23 | resolve(), 24 | commonjs(), 25 | typescript({ tsconfig: './tsconfig.json' }) 26 | ] 27 | }, 28 | { 29 | input: 'dist/esm/index.d.ts', 30 | output: [{ file: 'dist/index.d.ts', format: 'esm' }], 31 | plugins: [dts()] 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /src/components/CheckboxInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Checkbox, CheckboxProps, FormControl, FormControlLabel 3 | } from '@mui/material' 4 | import { useField } from 'formik' 5 | import FieldHelperText from './FieldHelperText' 6 | 7 | export type CheckboxInputProps = CheckboxProps & { 8 | helperText?: string 9 | name: string 10 | label?: string 11 | } 12 | 13 | export default function CheckboxInput ({ 14 | helperText, 15 | label, 16 | name, 17 | ...props 18 | }: CheckboxInputProps): JSX.Element { 19 | const [field] = useField(name) 20 | 21 | return ( 22 | 23 | } 25 | label={label} 26 | /> 27 | {helperText && } 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/transformString.ts: -------------------------------------------------------------------------------- 1 | export function transformString( 2 | inputStr: string, 3 | _transformation?: 'capitalize' | 'split_capitalize' | 'pascal', 4 | ): string { 5 | const transformation = _transformation ?? 'capitalize' 6 | 7 | if (transformation === 'capitalize') { 8 | return inputStr[0].toUpperCase() + inputStr.slice(1) 9 | } if (transformation === 'split_capitalize') { 10 | return inputStr 11 | .split('_') 12 | .map((chunk) => chunk[0].toUpperCase() + chunk.slice(1)) 13 | .join(' ') 14 | } if (transformation === 'pascal') { 15 | let converted = '' 16 | 17 | inputStr.split('').forEach((char) => { 18 | const charCode = char.charCodeAt(0) 19 | if (charCode >= 65 && charCode <= 90) { 20 | converted += ` ${char.toLowerCase()}` 21 | } else { 22 | converted += char 23 | } 24 | }) 25 | 26 | return converted.charAt(0).toUpperCase() + converted.slice(1) 27 | } 28 | 29 | return inputStr 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true 6 | }, 7 | extends: [ 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "airbnb", 11 | "airbnb-typescript", 12 | "plugin:import/typescript", 13 | "plugin:react/jsx-runtime", 14 | ], 15 | parser: "@typescript-eslint/parser", 16 | overrides: [], 17 | parserOptions: { 18 | project: "./tsconfig.json", 19 | ecmaFeatures: { 20 | jsx: true, 21 | }, 22 | ecmaVersion: "latest", 23 | sourceType: "module", 24 | }, 25 | plugins: ["react", "@typescript-eslint"], 26 | rules: { 27 | "@typescript-eslint/strict-boolean-expressions": "off", 28 | "no-multiple-empty-lines": "off", 29 | "@typescript-eslint/space-before-function-paren": "off", 30 | "@typescript-eslint/semi": "off", 31 | "import/prefer-default-export": "off", 32 | "@typescript-eslint/comma-dangle": "off", 33 | "no-nested-ternary": "off", 34 | "react/jsx-props-no-spreading": "off", 35 | "react/require-default-props": "off", 36 | "react/prop-types": "off" 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/FieldLabel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, FormHelperText, FormLabel, Typography, useTheme 3 | } from '@mui/material' 4 | 5 | export interface FieldLabelProps { 6 | name: string 7 | label: string 8 | error?: string | boolean 9 | required?: boolean 10 | } 11 | 12 | export default function FieldLabel (props: FieldLabelProps): JSX.Element { 13 | const theme = useTheme() 14 | const { 15 | name, required, label, error 16 | } = props 17 | const requiredLabel = ( 18 | 19 | {label} 20 | {required 21 | ? ( 22 | 23 | * 24 | 25 | ) 26 | : ( 27 | '' 28 | )} 29 | 30 | ) 31 | return ( 32 | 33 | 34 | {requiredLabel} 35 | {Boolean(error) && ( 36 | 37 | {error} 38 | 39 | )} 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/PasswordInput.tsx: -------------------------------------------------------------------------------- 1 | import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined' 2 | import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined' 3 | import { IconButton, InputAdornment } from '@mui/material' 4 | import { useState } from 'react' 5 | import TextInput, { TextInputProps } from './TextInput' 6 | 7 | export default function PasswordInput (props: TextInputProps): JSX.Element { 8 | const [isShowingPass, setIsShowingPass] = useState(false) 9 | 10 | const handleClickShowPassword = (): void => { 11 | setIsShowingPass((prev) => !prev) 12 | } 13 | 14 | return ( 15 | 27 | 28 | {isShowingPass 29 | ? ( 30 | 31 | ) 32 | : ( 33 | 34 | )} 35 | 36 | 37 | ) 38 | }} 39 | {...props} 40 | /> 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/RatingInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | FormControlProps, 4 | Rating, 5 | RatingProps 6 | } from '@mui/material' 7 | import { useField } from 'formik' 8 | import FieldHelperText from './FieldHelperText' 9 | import FieldLabel from './FieldLabel' 10 | 11 | export type RatingInputProps = Omit & { 12 | name: string 13 | label?: string 14 | required?: boolean 15 | formControlProps?: FormControlProps 16 | helperText?: string 17 | } 18 | 19 | export default function RatingInput ({ 20 | label, 21 | name, 22 | required, 23 | formControlProps = {}, 24 | helperText, 25 | ...props 26 | }: RatingInputProps): JSX.Element { 27 | const [field, , { setTouched, setValue }] = useField(name) 28 | 29 | const labelField = label 30 | ? ( 31 | 32 | ) 33 | : null 34 | 35 | return ( 36 | 40 | {labelField} 41 | { 44 | setTouched(true, true) 45 | }} 46 | precision={0.5} 47 | {...field} 48 | {...props} 49 | onChange={(_, value) => { 50 | setValue(Number(value)) 51 | }} 52 | /> 53 | {helperText && } 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/components/DatePickerInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, FormControl, FormControlProps, TextField 3 | } from '@mui/material' 4 | import { DatePicker, DatePickerProps } from '@mui/x-date-pickers' 5 | import { useField } from 'formik' 6 | import FieldHelperText from './FieldHelperText' 7 | import FieldLabel from './FieldLabel' 8 | 9 | export type DatePickerInputProps = Omit< 10 | DatePickerProps, 11 | 'value' | 'label' | 'onChange' | 'renderInput' 12 | > & { 13 | helperText?: string 14 | name: string 15 | label?: string 16 | formControlProps?: FormControlProps 17 | required?: boolean 18 | } 19 | 20 | export default function DatePickerInput ({ 21 | helperText, 22 | label, 23 | name, 24 | required, 25 | formControlProps = {}, 26 | ...props 27 | }: DatePickerInputProps): JSX.Element { 28 | const [field, { value }, { setValue }] = useField(name) 29 | 30 | const labelField = label 31 | ? ( 32 | 33 | ) 34 | : null 35 | 36 | return ( 37 | 38 | {labelField} 39 | 40 | {...props} 41 | value={value} 42 | onChange={(date) => { 43 | if (date) { 44 | setValue(date.toString()) 45 | } 46 | }} 47 | renderInput={(params) => ( 48 | 56 | )} 57 | /> 58 | {helperText && ( 59 | 60 | 61 | 62 | )} 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@reinforz/formand", 3 | "version": "0.0.1", 4 | "description": "Build beautiful and interactive forms with a simple json configuration", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/esm/index.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1", 13 | "build": "rollup -c", 14 | "lint": "eslint .", 15 | "lint:fix": "eslint --fix" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/NLP-practitioners/formand.git" 20 | }, 21 | "author": "Safwan Shaheer ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/NLP-practitioners/formand/issues" 25 | }, 26 | "homepage": "https://github.com/NLP-practitioners/formand#readme", 27 | "devDependencies": { 28 | "@rollup/plugin-commonjs": "^22.0.2", 29 | "@rollup/plugin-node-resolve": "^14.1.0", 30 | "@rollup/plugin-typescript": "^8.5.0", 31 | "@types/react": "^17.0.0", 32 | "@typescript-eslint/eslint-plugin": "^5.37.0", 33 | "@typescript-eslint/parser": "^5.37.0", 34 | "eslint": "^8.23.1", 35 | "eslint-config-airbnb": "^19.0.4", 36 | "eslint-config-airbnb-typescript": "^17.0.0", 37 | "eslint-config-prettier": "^8.5.0", 38 | "eslint-plugin-import": "^2.26.0", 39 | "eslint-plugin-jsx-a11y": "^6.6.1", 40 | "eslint-plugin-n": "^15.2.5", 41 | "eslint-plugin-prettier": "^4.2.1", 42 | "eslint-plugin-promise": "^6.0.1", 43 | "eslint-plugin-react": "^7.31.8", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "prettier": "^2.7.1", 46 | "rollup": "^2.79.0", 47 | "rollup-plugin-dts": "^4.2.2", 48 | "typescript": "^4.8.3" 49 | }, 50 | "dependencies": { 51 | "@emotion/react": "^11.10.4", 52 | "@emotion/styled": "^11.10.4", 53 | "@mui/icons-material": "^5.8.0", 54 | "@mui/material": "^5.8.1", 55 | "@mui/system": "^5.8.1", 56 | "@mui/x-date-pickers": "^5.0.1", 57 | "formik": "^2.2.9", 58 | "react": "^18.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/TagsInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Autocomplete, 3 | AutocompleteProps, 4 | Box, 5 | Chip, 6 | FormControl, 7 | TextField 8 | } from '@mui/material' 9 | import { useField } from 'formik' 10 | import FieldHelperText from './FieldHelperText' 11 | import FieldLabel from './FieldLabel' 12 | 13 | export interface TagsInputProps 14 | extends Omit< 15 | AutocompleteProps, 16 | 'options' | 'renderInput' 17 | > { 18 | name: string 19 | required?: boolean 20 | label: string 21 | max?: number 22 | placeholder?: string 23 | helperText?: string 24 | } 25 | 26 | export default function TagsInput (props: TagsInputProps): JSX.Element { 27 | const { 28 | placeholder, 29 | max = 5, 30 | required = true, 31 | label, 32 | name, 33 | helperText, 34 | ...rest 35 | } = props 36 | const [field, { value }, { setValue }] = useField(name) 37 | return ( 38 | 39 | 40 | 41 | multiple 42 | options={[]} 43 | freeSolo 44 | renderTags={(tags, getTagProps) => tags.map((tag, index) => ( 45 | 46 | ))} 47 | onChange={(_, tags) => { 48 | setValue( 49 | tags.map((tag) => tag.toLowerCase().trim().split(' ').join()) 50 | ) 51 | }} 52 | value={value} 53 | renderInput={(params) => ( 54 | 64 | )} 65 | {...rest} 66 | /> 67 | {helperText && ( 68 | 69 | 70 | 71 | )} 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/RangeInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, FormControl, FormControlProps, Stack, TextField 3 | } from '@mui/material' 4 | import { useField } from 'formik' 5 | import FieldHelperText from './FieldHelperText' 6 | import FieldLabel from './FieldLabel' 7 | 8 | export interface RangeInputProps { 9 | helperText?: string 10 | name: string 11 | label?: string 12 | formControlProps?: FormControlProps 13 | required?: boolean 14 | } 15 | 16 | export default function RangeInput ({ 17 | helperText, 18 | label, 19 | name, 20 | required = false, 21 | formControlProps = {} 22 | }: RangeInputProps): JSX.Element { 23 | const [field, { error, touched, value }, { setTouched, setValue }] = useField(name) 24 | 25 | const errorState = touched ? Boolean(error) : false 26 | 27 | const labelField = label 28 | ? ( 29 | 37 | ) 38 | : null 39 | 40 | return ( 41 | 42 | {labelField} 43 | 44 | { 49 | setTouched(true, true) 50 | }} 51 | type="number" 52 | onChange={(e) => { 53 | setValue([Number(e.target.value), value[1]]) 54 | }} 55 | value={value[0]} 56 | InputProps={{ 57 | inputProps: { 58 | min: 0, 59 | max: value[1], 60 | step: 5 61 | } 62 | }} 63 | /> 64 | { 69 | setValue([value[0], Number(e.target.value)]) 70 | }} 71 | onClick={() => { 72 | setTouched(true, true) 73 | }} 74 | type="number" 75 | value={value[1]} 76 | InputProps={{ 77 | inputProps: { 78 | min: value[0], 79 | step: 5 80 | } 81 | }} 82 | /> 83 | 84 | {helperText && ( 85 | 86 | 87 | 88 | )} 89 | 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/components/SelectInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl, 3 | FormControlProps, 4 | MenuItem, 5 | Select, 6 | SelectProps, 7 | Typography 8 | } from '@mui/material' 9 | import { useField } from 'formik' 10 | import { transformString } from '../utils' 11 | import FieldHelperText from './FieldHelperText' 12 | import FieldLabel from './FieldLabel' 13 | 14 | export type SelectInputProps = SelectProps & { 15 | helperText?: string 16 | name: string 17 | label?: string 18 | placeholder?: string | number 19 | values: 20 | | string[] 21 | | readonly string[] 22 | | Array<{ 23 | value: string 24 | label: string 25 | }> 26 | formControlProps?: FormControlProps 27 | transformation?: 'capitalize' | 'split_capitalize' 28 | } 29 | 30 | export default function SelectInput ({ 31 | helperText, 32 | label, 33 | placeholder, 34 | multiline = false, 35 | rows = 1, 36 | fullWidth = true, 37 | name, 38 | values, 39 | formControlProps = {}, 40 | transformation = 'split_capitalize', 41 | ...props 42 | }: SelectInputProps): JSX.Element { 43 | const [field, { error, value: selectValue }] = useField(name) 44 | return ( 45 | 46 | {label && } 47 | 48 | fullWidth={fullWidth} 49 | multiline={multiline} 50 | rows={rows} 51 | error={Boolean(error)} 52 | id={field.name} 53 | displayEmpty 54 | placeholder={placeholder ?? label} 55 | renderValue={(value) => { 56 | if (Array.isArray(value)) { 57 | if (value.length === 0) { 58 | return None selected 59 | } 60 | return value 61 | .map((val) => transformString(val, transformation)) 62 | .join(',') 63 | } 64 | if (!value) { 65 | return 'None' 66 | } 67 | return transformString(value, transformation) 68 | }} 69 | {...field} 70 | {...props} 71 | value={selectValue} 72 | required={!Array.isArray(selectValue)} 73 | > 74 | {values.map((value) => (typeof value === 'string' 75 | ? ( 76 | 77 | {transformString(value, transformation)} 78 | 79 | ) 80 | : ( 81 | 82 | {value.label} 83 | 84 | )))} 85 | 86 | {helperText && } 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { FormikContextType, FormikState } from 'formik'; 2 | import type { CheckboxInputProps } from './components/CheckboxInput'; 3 | 4 | export type FormInput> = 5 | | { 6 | name: keyof RequestPayload | (string & Record); 7 | input: 8 | | 'rating' 9 | | 'password' 10 | | 'text' 11 | | 'number' 12 | | 'range' 13 | | 'tags' 14 | | 'date'; 15 | onMount?: (formikContext: FormikContextType) => any; 16 | } 17 | | { 18 | name: keyof RequestPayload | (string & Record); 19 | input: 'text-multi'; 20 | rows?: number; 21 | maxLength?: number; 22 | } 23 | | { 24 | name: keyof RequestPayload | (string & Record); 25 | input: 'select'; 26 | multiple?: boolean; 27 | values: string[] | readonly string[]; 28 | transformation?: 'capitalize' | 'split_capitalize'; 29 | } 30 | | { 31 | name: keyof RequestPayload | (string & Record); 32 | input: 'checkbox'; 33 | checked?: boolean; 34 | onClick?: CheckboxInputProps['onClick']; 35 | }; 36 | 37 | export type RegularFormInput> = { 38 | type?: 'regular'; 39 | } & FormInput; 40 | 41 | export type GroupFormInput> = { 42 | type: 'group'; 43 | items: RegularFormInput[]; 44 | sizes?: number[]; 45 | name: string; 46 | collection?: boolean; 47 | }; 48 | 49 | export type DynamicCollectionFormInput< 50 | RequestPayload extends Record 51 | > = FormInput & { 52 | type: 'collection'; 53 | maxItems: number; 54 | minItems: number; 55 | selectionFormKey?: string; 56 | selectMulti?: boolean; 57 | }; 58 | 59 | export type StaticCollectionFormInput< 60 | RequestPayload extends Record 61 | > = FormInput & { 62 | type: 'collection-static'; 63 | labels: string[]; 64 | selectionFormKey?: string; 65 | selectMulti?: boolean; 66 | }; 67 | 68 | export type FormInputExcludingCb> = 69 | | GroupFormInput 70 | | RegularFormInput 71 | | DynamicCollectionFormInput 72 | | StaticCollectionFormInput; 73 | 74 | export type FormInputs> = ( 75 | | (FormInputExcludingCb | null) 76 | | (( 77 | values: FormikState['values'] 78 | ) => FormInputExcludingCb | null) 79 | )[]; 80 | 81 | export type FormConstants = { 82 | label?: Partial>; 83 | placeholder?: Partial>; 84 | submitButtonText: string; 85 | onLoadButtonText: string; 86 | formHeaderText: string; 87 | formHeaderHelperText?: string; 88 | helperText?: Partial>; 89 | optionalFields?: (keyof Payload)[]; 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | FormControl, 4 | FormControlProps, 5 | Stack, 6 | TextField, 7 | TextFieldProps 8 | } from '@mui/material'; 9 | import { useField } from 'formik'; 10 | import { ReactNode } from 'react'; 11 | import FieldHelperText from './FieldHelperText'; 12 | import FieldLabel from './FieldLabel'; 13 | 14 | export type TextInputProps = TextFieldProps & { 15 | helperText?: string; 16 | name: string; 17 | label?: string; 18 | placeholder?: string | number; 19 | formControlProps?: FormControlProps; 20 | maxLength?: number; 21 | startComponent?: ReactNode; 22 | endComponent?: ReactNode; 23 | }; 24 | 25 | export default function TextInput({ 26 | helperText, 27 | label, 28 | placeholder, 29 | multiline = false, 30 | rows = 1, 31 | fullWidth = true, 32 | name, 33 | required, 34 | formControlProps = {}, 35 | maxLength, 36 | endComponent = null, 37 | startComponent = null, 38 | ...props 39 | }: TextInputProps) { 40 | const [ 41 | field, 42 | { error, touched, value }, 43 | { setTouched, setValue } 44 | ] = useField(name); 45 | 46 | const val = Array.isArray(value) ? value[0] : value; 47 | const errorMessage = Array.isArray(error) ? error[0] : error; 48 | const errorState = touched ? Boolean(errorMessage) : false; 49 | 50 | const labelField = label ? ( 51 | 64 | ) : null; 65 | 66 | let maxCharLengthField: ReactNode = null; 67 | 68 | const inputProps: Partial = {}; 69 | 70 | if (maxLength) { 71 | inputProps.inputProps = { maxLength }; 72 | maxCharLengthField = ( 73 | 79 | ); 80 | } 81 | 82 | return ( 83 | 84 | <> 85 | {labelField} 86 | {' '} 87 | {maxCharLengthField} 88 | 89 | <> 90 | {startComponent} 91 | { 99 | setTouched(true, true); 100 | }} 101 | {...inputProps} 102 | {...field} 103 | {...props} 104 | onChange={(e) => { 105 | const textValue = e.target.value; 106 | setValue(Array.isArray(value) ? [textValue] : textValue); 107 | }} 108 | /> 109 | {endComponent} 110 | 111 | 112 | {helperText && ( 113 | 114 | 115 | 116 | )} 117 | 118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/components/FormikForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, Button, Divider, Stack, SxProps, Typography 3 | } from '@mui/material' 4 | import { Form, Formik, FormikConfig } from 'formik' 5 | import { ReactNode } from 'react' 6 | import { FormConstants, FormInputs } from '../types' 7 | import { FormikFormInputs } from './FormikFormInput' 8 | import LoadingButton from './LoadingButton' 9 | 10 | export interface FormikFormProps> { 11 | formPreSubmit?: ReactNode 12 | formFooter?: ReactNode 13 | formConstants: FormConstants & { 14 | payloadFactory: () => Payload 15 | validationSchema: any 16 | } 17 | formInputs: FormInputs 18 | onSubmit: FormikConfig['onSubmit'] 19 | formBodySx?: SxProps 20 | formSx?: SxProps 21 | isLoading?: boolean 22 | } 23 | 24 | export function FormikForm> ( 25 | props: FormikFormProps 26 | ): JSX.Element { 27 | const { 28 | isLoading = false, 29 | formFooter, 30 | formConstants: { 31 | payloadFactory, 32 | validationSchema, 33 | formHeaderText, 34 | onLoadButtonText, 35 | submitButtonText, 36 | label, 37 | placeholder, 38 | helperText, 39 | optionalFields = [], 40 | formHeaderHelperText 41 | }, 42 | formBodySx = {}, 43 | formInputs, 44 | formPreSubmit, 45 | formSx = {}, 46 | onSubmit 47 | } = props 48 | 49 | return ( 50 | 58 | {({ isSubmitting, isValid }) => ( 59 | 60 |
66 | 76 | 84 | {formHeaderText} 85 | 86 | {formHeaderHelperText && ( 87 | 91 | {formHeaderHelperText} 92 | 93 | )} 94 | 99 | 100 | 101 | formInputs={formInputs} 102 | label={label} 103 | placeholder={placeholder as any} 104 | helperText={helperText} 105 | optionalFields={optionalFields} 106 | isDisabled={isSubmitting} 107 | sx={formBodySx} 108 | mb={2} 109 | gap={2} 110 | /> 111 | {formPreSubmit} 112 | 113 | {isLoading 114 | ? ( 115 | {onLoadButtonText} 116 | ) 117 | : ( 118 | 121 | )} 122 | 123 | {formFooter} 124 | 125 |
126 | )} 127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /src/components/Collections/CollectionTextInput.tsx: -------------------------------------------------------------------------------- 1 | import AddIcon from '@mui/icons-material/Add'; 2 | import DeleteIcon from '@mui/icons-material/Delete'; 3 | import { 4 | Box, 5 | Button, 6 | Checkbox, 7 | Divider, 8 | IconButton, 9 | Radio, 10 | Stack, 11 | Tooltip, 12 | Typography 13 | } from '@mui/material'; 14 | import { useField } from 'formik'; 15 | import { useEffect, useState } from 'react'; 16 | import { DynamicCollectionFormInput, StaticCollectionFormInput } from '../../types'; 17 | import { transformString } from '../../utils'; 18 | import TextInput, { TextInputProps } from '../TextInput'; 19 | 20 | function CollectionTextInputCheckbox({ 21 | selectionFormKey, 22 | selectMulti, 23 | index 24 | }: { 25 | index: number; 26 | selectionFormKey: string; 27 | selectMulti: boolean; 28 | }) { 29 | const [{ value }, , { setValue }] = useField(selectionFormKey); 30 | 31 | return selectMulti ? ( 32 | { 38 | if (e.target.checked) { 39 | setValue(value.concat(index)); 40 | } else { 41 | setValue(value.filter((val) => val !== index)); 42 | } 43 | }} 44 | /> 45 | ) : ( 46 | { 52 | if (e.target.checked) { 53 | setValue([index]); 54 | } 55 | }} 56 | /> 57 | ); 58 | } 59 | 60 | export function StaticCollectionTextInput< 61 | RequestPayload extends Record 62 | >( 63 | props: TextInputProps & 64 | Pick< 65 | StaticCollectionFormInput, 66 | 'labels' | 'selectionFormKey' | 'selectMulti' 67 | > 68 | ) { 69 | const { 70 | name, selectionFormKey, selectMulti, label, labels, ...rest 71 | } = props; 72 | 73 | return ( 74 | 75 | 76 | {transformString(label as string, 'capitalize')} 77 | 78 | 83 | 84 | {labels.map((_label, i) => ( 85 | 93 | ) 94 | } 95 | key={`collection-text-${i.toString()}`} 96 | {...rest} 97 | name={`${name}[${i}]`} 98 | value={_label} 99 | required 100 | disabled 101 | /> 102 | ))} 103 | 104 | 105 | ); 106 | } 107 | 108 | export function DynamicCollectionTextInput< 109 | RequestPayload extends Record 110 | >( 111 | props: TextInputProps & 112 | Pick< 113 | DynamicCollectionFormInput, 114 | 'maxItems' | 'minItems' | 'selectionFormKey' | 'selectMulti' 115 | > 116 | ) { 117 | const { 118 | name, 119 | selectionFormKey, 120 | selectMulti, 121 | label, 122 | minItems, 123 | maxItems, 124 | ...rest 125 | } = props; 126 | const [{ value }, , { setValue }] = useField(name as string); 127 | const [totalItems, setTotalItems] = useState(minItems); 128 | 129 | useEffect(() => { 130 | setTotalItems(minItems); 131 | }, [minItems]); 132 | 133 | return ( 134 | 135 | 136 | {transformString(label as string, 'capitalize')} 137 | 138 | 139 | 140 | {new Array(totalItems).fill(0).map((_, i) => ( 141 | 149 | ) 150 | } 151 | key={`collection-text-${i.toString()}`} 152 | {...rest} 153 | name={`${name}[${i}]`} 154 | value={value[i] ?? ''} 155 | label={transformString(`${label} ${i + 1}`, 'capitalize')} 156 | required 157 | endComponent={( 158 | 165 |
166 | { 170 | setValue(value.filter((__, index) => index !== i)); 171 | setTotalItems(totalItems - 1); 172 | }} 173 | sx={{ 174 | p: 0 175 | }} 176 | > 177 | 183 | 184 |
185 |
186 | )} 187 | /> 188 | ))} 189 | 196 | 201 | 217 | 218 | 219 |
220 |
221 | ); 222 | } 223 | -------------------------------------------------------------------------------- /src/components/FormikFormInput.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack, StackProps } from '@mui/material'; 2 | import { FormikContextType, useFormikContext } from 'formik'; 3 | import { useEffect } from 'react'; 4 | import { 5 | DynamicCollectionFormInput, 6 | FormConstants, 7 | FormInputExcludingCb, 8 | FormInputs, 9 | RegularFormInput, 10 | StaticCollectionFormInput 11 | } from '../types'; 12 | import { transformString } from '../utils'; 13 | import CheckboxInput from './CheckboxInput'; 14 | import { 15 | DynamicCollectionTextInput, 16 | StaticCollectionTextInput 17 | } from './Collections/CollectionTextInput'; 18 | import DatePickerInput from './DatePickerInput'; 19 | import PasswordInput from './PasswordInput'; 20 | import RangeInput from './RangeInput'; 21 | import RatingInput from './RatingInput'; 22 | import SelectInput from './SelectInput'; 23 | import TagsInput from './TagsInput'; 24 | import TextInput from './TextInput'; 25 | 26 | type CommonFormInput> = { 27 | autoFocus: boolean; 28 | required: boolean; 29 | placeholder?: string; 30 | label?: keyof RequestPayload | (string & Record); 31 | disabled: boolean; 32 | helperText?: string; 33 | maxLength?: number; 34 | onMount?: (formikContext: FormikContextType) => void; 35 | }; 36 | 37 | export function RegularFormikFormInput< 38 | RequestPayload extends Record 39 | >(props: CommonFormInput & RegularFormInput) { 40 | const { 41 | disabled, 42 | placeholder, 43 | helperText, 44 | required, 45 | label, 46 | autoFocus, 47 | input, 48 | name, 49 | maxLength, 50 | onMount 51 | } = props; 52 | const context = useFormikContext(); 53 | 54 | const transformedLabel = (label 55 | ?? transformString(name as string), 'pascal') as string; 56 | const fieldCommonProps = { 57 | label: transformedLabel, 58 | disabled, 59 | placeholder, 60 | name: name as string, 61 | required, 62 | helperText 63 | }; 64 | 65 | useEffect(() => { 66 | if (onMount) { 67 | onMount(context); 68 | } 69 | }, []); 70 | 71 | switch (input) { 72 | case 'password': { 73 | return ; 74 | } 75 | case 'text': { 76 | return ; 77 | } 78 | case 'text-multi': { 79 | const { rows = 5 } = props; 80 | return ( 81 | 88 | ); 89 | } 90 | case 'select': { 91 | const { values, transformation, multiple = false } = props; 92 | 93 | return ( 94 | 100 | ); 101 | } 102 | case 'checkbox': { 103 | const { checked, onClick } = props; 104 | 105 | return ( 106 | 111 | ); 112 | } 113 | 114 | case 'rating': { 115 | return ; 116 | } 117 | 118 | case 'number': { 119 | return ; 120 | } 121 | case 'tags': { 122 | return ; 123 | } 124 | case 'date': { 125 | return ; 126 | } 127 | case 'range': { 128 | return ; 129 | } 130 | default: { 131 | return null; 132 | } 133 | } 134 | } 135 | 136 | export function DynamicCollectionFormikFormInput< 137 | RequestPayload extends Record 138 | >( 139 | props: CommonFormInput & 140 | DynamicCollectionFormInput 141 | ) { 142 | const { 143 | disabled, 144 | placeholder, 145 | helperText, 146 | required, 147 | label, 148 | autoFocus, 149 | name, 150 | input, 151 | maxItems, 152 | minItems, 153 | onMount, 154 | selectMulti, 155 | selectionFormKey 156 | } = props; 157 | const context = useFormikContext(); 158 | 159 | const transformedLabel = (label 160 | ?? transformString(name as string), 'pascal') as string; 161 | const fieldCommonProps = { 162 | label: transformedLabel, 163 | disabled, 164 | placeholder, 165 | name: name as string, 166 | required, 167 | helperText 168 | }; 169 | 170 | useEffect(() => { 171 | if (onMount) { 172 | onMount(context); 173 | } 174 | }, []); 175 | 176 | switch (input) { 177 | case 'text': { 178 | return ( 179 | 187 | ); 188 | } 189 | 190 | default: { 191 | return null; 192 | } 193 | } 194 | } 195 | 196 | export function StaticCollectionFormikFormInput< 197 | RequestPayload extends Record 198 | >( 199 | props: CommonFormInput & 200 | StaticCollectionFormInput 201 | ) { 202 | const { 203 | disabled, 204 | placeholder, 205 | helperText, 206 | required, 207 | label, 208 | autoFocus, 209 | name, 210 | input, 211 | labels, 212 | onMount, 213 | selectMulti, 214 | selectionFormKey 215 | } = props; 216 | const context = useFormikContext(); 217 | 218 | const transformedLabel = (label 219 | ?? transformString(name as string), 'pascal') as string; 220 | const fieldCommonProps = { 221 | label: transformedLabel, 222 | disabled, 223 | placeholder, 224 | name: name as string, 225 | required, 226 | helperText 227 | }; 228 | 229 | useEffect(() => { 230 | if (onMount) { 231 | onMount(context); 232 | } 233 | }, []); 234 | 235 | switch (input) { 236 | case 'text': { 237 | return ( 238 | 245 | ); 246 | } 247 | 248 | default: { 249 | return null; 250 | } 251 | } 252 | } 253 | 254 | export type FormikFormInputsProps> = { 255 | isDisabled?: boolean; 256 | formInputs: FormInputs; 257 | } & Pick< 258 | FormConstants, 259 | 'helperText' | 'label' | 'placeholder' | 'optionalFields' 260 | > & 261 | Partial; 262 | 263 | export function FormikFormInputs>({ 264 | formInputs, 265 | helperText, 266 | label, 267 | optionalFields = [], 268 | placeholder, 269 | isDisabled = false, 270 | ...stackProps 271 | }: FormikFormInputsProps) { 272 | const { values } = useFormikContext(); 273 | 274 | function formikFormInput({ 275 | formInputIndex, 276 | formInputItem 277 | }: { 278 | formInputIndex: number; 279 | formInputItem: FormInputs[0]; 280 | }) { 281 | let formInput: FormInputExcludingCb | null = null; 282 | if (formInputItem instanceof Function) { 283 | formInput = formInputItem(values); 284 | } else { 285 | formInput = formInputItem; 286 | } 287 | if (!formInputItem || !formInput) { 288 | return null; 289 | } 290 | const commonProps = { 291 | autoFocus: formInputIndex === 0, 292 | placeholder: placeholder?.[formInput.name], 293 | required: !optionalFields.includes(formInput.name), 294 | helperText: helperText?.[formInput.name], 295 | label: label?.[formInput.name], 296 | disabled: isDisabled, 297 | key: `${label?.[formInput.name]}.${formInputIndex}` 298 | }; 299 | 300 | if (formInput.type === 'collection') { 301 | return ( 302 | 303 | {...commonProps} 304 | {...formInput} 305 | /> 306 | ); 307 | } if (formInput.type === 'regular' || formInput.type === undefined) { 308 | return ( 309 | 310 | {...commonProps} 311 | {...formInput} 312 | /> 313 | ); 314 | } if (formInput.type === 'collection-static') { 315 | return ( 316 | 317 | {...commonProps} 318 | {...formInput} 319 | /> 320 | ); 321 | } if (formInput.type === 'group') { 322 | const { sizes } = formInput; 323 | const totalItems = formInput.items.length; 324 | return ( 325 | 326 | {formInput.items.map((_formInputItem, itemIndex) => (formInput ? ( 327 | 331 | {formikFormInput({ 332 | formInputIndex: itemIndex, 333 | formInputItem: _formInputItem 334 | })} 335 | 336 | ) : null))} 337 | 338 | ); 339 | } 340 | return null; 341 | } 342 | 343 | return ( 344 | 345 | {formInputs.map((formInput, formInputIndex) => formikFormInput({ 346 | formInputIndex, 347 | formInputItem: formInput 348 | }))} 349 | 350 | ); 351 | } 352 | --------------------------------------------------------------------------------