├── .gitignore ├── LICENSE ├── README.md ├── docs ├── index.4aaee98c.js ├── index.4aaee98c.js.map ├── index.8c9e9738.css ├── index.8c9e9738.css.map └── index.html ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── generate-exports.js ├── package.json ├── src ├── AutoSave.ts ├── FieldAssistiveText.tsx ├── FieldErrorMessage.tsx ├── FieldLabel.tsx ├── FieldSkeleton.tsx ├── FieldWrapper.tsx ├── Fields │ ├── Checkbox │ │ ├── CheckboxComponent.tsx │ │ └── index.tsx │ ├── Color │ │ ├── ColorComponent.tsx │ │ ├── ColorSettings.ts │ │ └── index.tsx │ ├── ContentHeader │ │ ├── ContentHeaderComponent.tsx │ │ ├── ContentHeaderSettings.ts │ │ └── index.tsx │ ├── ContentImage │ │ ├── ContentImageComponent.tsx │ │ ├── ContentImageSettings.ts │ │ └── index.tsx │ ├── ContentParagraph │ │ ├── ContentParagraphComponent.tsx │ │ ├── ContentParagraphSettings.ts │ │ └── index.tsx │ ├── ContentSubHeader │ │ ├── ContentSubHeaderComponent.tsx │ │ ├── ContentSubHeaderSettings.ts │ │ └── index.tsx │ ├── Date │ │ ├── DateComponent.tsx │ │ └── index.tsx │ ├── DateTime │ │ ├── DateTimeComponent.tsx │ │ └── index.tsx │ ├── Hidden │ │ ├── HiddenComponent.tsx │ │ ├── HiddenSettings.ts │ │ └── index.tsx │ ├── List │ │ ├── ListComponent.tsx │ │ ├── ListItem.tsx │ │ ├── ListSettings.ts │ │ └── index.tsx │ ├── MultiSelect │ │ ├── MultiSelectComponent.tsx │ │ ├── MultiSelectSettings.ts │ │ └── index.tsx │ ├── Paragraph │ │ ├── ParagraphComponent.tsx │ │ ├── ParagraphSettings.ts │ │ └── index.tsx │ ├── Radio │ │ ├── RadioComponent.tsx │ │ ├── RadioSettings.ts │ │ └── index.tsx │ ├── Score │ │ ├── ScoreComponent.tsx │ │ ├── ScoreSettings.ts │ │ └── index.tsx │ ├── ShortText │ │ ├── ShortTextComponent.tsx │ │ ├── ShortTextSettings.ts │ │ ├── ShortTextValidation.ts │ │ └── index.tsx │ ├── SingleSelect │ │ ├── SingleSelectComponent.tsx │ │ ├── SingleSelectSettings.ts │ │ └── index.tsx │ ├── Slider │ │ ├── SliderComponent.tsx │ │ ├── SliderSettings.ts │ │ └── index.tsx │ └── index.ts ├── Form.tsx ├── FormDialog.tsx ├── FormFields.tsx ├── FormWithContext.tsx ├── ScrollableDialogContent.tsx ├── SubmitButton.tsx ├── SubmitError.tsx ├── constants │ └── fields.ts ├── index.ts ├── types.ts ├── useFormSettings.ts └── utils.ts ├── tsconfig.json ├── tsdx.config.js ├── types └── react-element-scroll-hook.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | .parcel-cache 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sidney Alcantara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @rowy/form-builder 2 | 3 | https://rowyio.github.io/form-builder/ 4 | -------------------------------------------------------------------------------- /docs/index.8c9e9738.css: -------------------------------------------------------------------------------- 1 | .rcp-light { 2 | --rcp-background: #ffffff; 3 | --rcp-input-text: #111111; 4 | --rcp-input-border: rgba(0, 0, 0, 0.1); 5 | --rcp-input-label: #717171; 6 | } 7 | 8 | .rcp-dark { 9 | --rcp-background: #181818; 10 | --rcp-input-text: #f3f3f3; 11 | --rcp-input-border: rgba(255, 255, 255, 0.1); 12 | --rcp-input-label: #999999; 13 | } 14 | 15 | .rcp { 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | 20 | background-color: var(--rcp-background); 21 | border-radius: 10px; 22 | } 23 | 24 | .rcp-body { 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | justify-content: center; 29 | gap: 20px; 30 | width: 100%; 31 | 32 | box-sizing: border-box; 33 | 34 | padding: 20px; 35 | } 36 | 37 | .rcp-saturation { 38 | position: relative; 39 | 40 | width: 100%; 41 | background-image: linear-gradient(transparent, black), 42 | linear-gradient(to right, white, transparent); 43 | border-radius: 10px 10px 0 0; 44 | 45 | user-select: none; 46 | } 47 | 48 | .rcp-saturation-cursor { 49 | position: absolute; 50 | 51 | width: 20px; 52 | height: 20px; 53 | 54 | border: 2px solid #ffffff; 55 | border-radius: 50%; 56 | box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.15); 57 | box-sizing: border-box; 58 | 59 | transform: translate(-10px, -10px); 60 | } 61 | 62 | .rcp-hue { 63 | position: relative; 64 | 65 | width: 100%; 66 | height: 12px; 67 | 68 | background-image: linear-gradient( 69 | to right, 70 | rgb(255, 0, 0), 71 | rgb(255, 255, 0), 72 | rgb(0, 255, 0), 73 | rgb(0, 255, 255), 74 | rgb(0, 0, 255), 75 | rgb(255, 0, 255), 76 | rgb(255, 0, 0) 77 | ); 78 | border-radius: 10px; 79 | 80 | user-select: none; 81 | } 82 | 83 | .rcp-hue-cursor { 84 | position: absolute; 85 | 86 | width: 20px; 87 | height: 20px; 88 | 89 | border: 2px solid #ffffff; 90 | border-radius: 50%; 91 | box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 0px 0.5px; 92 | box-sizing: border-box; 93 | 94 | transform: translate(-10px, -4px); 95 | } 96 | 97 | .rcp-alpha { 98 | position: relative; 99 | 100 | width: 100%; 101 | height: 12px; 102 | 103 | border-radius: 10px; 104 | 105 | user-select: none; 106 | } 107 | 108 | .rcp-alpha-cursor { 109 | position: absolute; 110 | 111 | width: 20px; 112 | height: 20px; 113 | 114 | border: 2px solid #ffffff; 115 | border-radius: 50%; 116 | box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 0px 0.5px; 117 | box-sizing: border-box; 118 | 119 | transform: translate(-10px, -4px); 120 | } 121 | 122 | .rcp-fields { 123 | display: grid; 124 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 125 | gap: 10px; 126 | 127 | width: 100%; 128 | } 129 | 130 | .rcp-fields-element { 131 | display: flex; 132 | flex-direction: column; 133 | align-items: center; 134 | gap: 5px; 135 | 136 | width: 100%; 137 | } 138 | 139 | .hex-element { 140 | grid-row: 1; 141 | } 142 | 143 | .hex-element:nth-child(3n) { 144 | grid-column: 1 / -1; 145 | } 146 | 147 | .rcp-fields-element-input { 148 | width: 100%; 149 | 150 | font-size: 14px; 151 | font-weight: 600; 152 | 153 | color: var(--rcp-input-text); 154 | text-align: center; 155 | 156 | background: none; 157 | border: 2px solid; 158 | border-color: var(--rcp-input-border); 159 | border-radius: 5px; 160 | box-sizing: border-box; 161 | 162 | outline: none; 163 | 164 | padding: 10px; 165 | } 166 | 167 | .rcp-fields-element-label { 168 | font-size: 14px; 169 | font-weight: 600; 170 | 171 | color: var(--rcp-input-label); 172 | text-transform: uppercase; 173 | } 174 | 175 | /*# sourceMappingURL=index.8c9e9738.css.map */ 176 | -------------------------------------------------------------------------------- /docs/index.8c9e9738.css.map: -------------------------------------------------------------------------------- 1 | {"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","sources":["../node_modules/react-color-palette/lib/css/styles.css"],"sourcesContent":[".rcp-light {\n --rcp-background: #ffffff;\n --rcp-input-text: #111111;\n --rcp-input-border: rgba(0, 0, 0, 0.1);\n --rcp-input-label: #717171;\n}\n\n.rcp-dark {\n --rcp-background: #181818;\n --rcp-input-text: #f3f3f3;\n --rcp-input-border: rgba(255, 255, 255, 0.1);\n --rcp-input-label: #999999;\n}\n\n.rcp {\n display: flex;\n flex-direction: column;\n align-items: center;\n\n background-color: var(--rcp-background);\n border-radius: 10px;\n}\n\n.rcp-body {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 20px;\n width: 100%;\n\n box-sizing: border-box;\n\n padding: 20px;\n}\n\n.rcp-saturation {\n position: relative;\n\n width: 100%;\n background-image: linear-gradient(transparent, black), linear-gradient(to right, white, transparent);\n border-radius: 10px 10px 0 0;\n \n user-select: none;\n}\n\n.rcp-saturation-cursor {\n position: absolute;\n\n width: 20px;\n height: 20px;\n\n border: 2px solid #ffffff;\n border-radius: 50%;\n box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.15);\n box-sizing: border-box;\n\n transform: translate(-10px, -10px);\n}\n\n.rcp-hue {\n position: relative;\n\n width: 100%;\n height: 12px;\n \n background-image: linear-gradient(\n to right,\n rgb(255, 0, 0),\n rgb(255, 255, 0),\n rgb(0, 255, 0),\n rgb(0, 255, 255),\n rgb(0, 0, 255),\n rgb(255, 0, 255),\n rgb(255, 0, 0)\n );\n border-radius: 10px;\n\n user-select: none\n}\n\n.rcp-hue-cursor {\n position: absolute;\n\n width: 20px;\n height: 20px;\n\n border: 2px solid #ffffff;\n border-radius: 50%;\n box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 0px 0.5px;\n box-sizing: border-box;\n\n transform: translate(-10px, -4px);\n}\n\n.rcp-alpha {\n position: relative;\n\n width: 100%;\n height: 12px;\n\n border-radius: 10px;\n\n user-select: none;\n}\n\n.rcp-alpha-cursor {\n position: absolute;\n\n width: 20px;\n height: 20px;\n\n border: 2px solid #ffffff;\n border-radius: 50%;\n box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 0px 0.5px;\n box-sizing: border-box;\n\n transform: translate(-10px, -4px);\n}\n\n.rcp-fields {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n gap: 10px;\n\n width: 100%;\n}\n\n.rcp-fields-element {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 5px;\n\n width: 100%;\n}\n\n.hex-element {\n grid-row: 1;\n}\n\n.hex-element:nth-child(3n) {\n grid-column: 1 / -1;\n}\n\n.rcp-fields-element-input {\n width: 100%;\n \n font-size: 14px;\n font-weight: 600;\n\n color: var(--rcp-input-text);\n text-align: center;\n\n background: none;\n border: 2px solid;\n border-color: var(--rcp-input-border);\n border-radius: 5px;\n box-sizing: border-box;\n \n outline: none;\n\n padding: 10px;\n}\n\n.rcp-fields-element-label {\n font-size: 14px;\n font-weight: 600;\n\n color: var(--rcp-input-label);\n text-transform: uppercase;\n}\n"],"names":[],"version":3,"file":"index.8c9e9738.css.map","sourceRoot":"/__parcel_source_root/"} -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Playground 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import createTheme from '@mui/material/styles/createTheme'; 5 | import ThemeProvider from '@mui/material/styles/ThemeProvider'; 6 | import CssBaseline from '@mui/material/CssBaseline'; 7 | import Button from '@mui/material/Button'; 8 | import FormDialog from '../src/FormDialog'; 9 | import { FieldType } from '../src'; 10 | 11 | import { DndProvider } from 'react-dnd'; 12 | import { HTML5Backend } from 'react-dnd-html5-backend'; 13 | 14 | const theme = createTheme({ 15 | typography: { fontFamily: 'system-ui' }, 16 | props: { MuiTextField: { variant: 'filled' } }, 17 | }); 18 | 19 | const fields = [ 20 | { 21 | type: FieldType.contentHeader, 22 | label: 'Header', 23 | name: '_contentHeader_1', 24 | }, 25 | { 26 | type: FieldType.contentParagraph, 27 | label: 'This is a link!', 28 | name: '_contentParagraph_0', 29 | }, 30 | // { 31 | // type: FieldType.richText, 32 | // name: 'desc', 33 | // label: 'Description', 34 | // maxCharacters: 20, 35 | // }, 36 | { 37 | type: FieldType.shortText, 38 | format: 'url', 39 | name: 'meetingLink', 40 | label: 'Meeting Link', 41 | assistiveText: `
42 |
43 | What is my meeting link? 44 |
45 |
46 | Calendly 47 |
48 |
49 | Hubspot 50 |
51 |
52 | `, 53 | required: true, 54 | }, 55 | { 56 | type: FieldType.shortText, 57 | format: 'url', 58 | name: 'link', 59 | label: 'Link', 60 | displayCondition: 'return values.email.length > 0', 61 | }, 62 | { 63 | type: FieldType.date, 64 | name: 'date', 65 | label: 'Your birthday', 66 | }, 67 | { 68 | type: FieldType.dateTime, 69 | name: 'dateTime', 70 | label: 'Book a time', 71 | 72 | // disable past date time 73 | minDateTime: new Date(), 74 | }, 75 | { 76 | type: FieldType.shortText, 77 | format: 'email', 78 | name: 'email', 79 | label: 'Email', 80 | gridCols: 6, 81 | }, 82 | { 83 | type: FieldType.shortText, 84 | format: 'emailWithName', 85 | name: 'emailWithName', 86 | label: 'Email with Name', 87 | gridCols: 6, 88 | }, 89 | { 90 | type: FieldType.shortText, 91 | format: 'phone', 92 | name: 'phone', 93 | label: 'Phone Number', 94 | gridCols: 6, 95 | }, 96 | { 97 | type: FieldType.shortText, 98 | format: 'twitter', 99 | name: 'twitter', 100 | label: 'Twitter', 101 | gridCols: 6, 102 | maxCharacters: 15, 103 | validation: { 104 | '0': { 0: 'notOneOf', 1: ['admin'], 2: 'Reserved username' }, 105 | 1: ['min', 3, 'Must be at least 3 characters'], 106 | }, 107 | }, 108 | { 109 | type: FieldType.shortText, 110 | format: 'linkedin', 111 | name: 'linkedin.text', 112 | label: 'LinkedIn', 113 | }, 114 | { 115 | type: FieldType.shortText, 116 | name: 'linkedin.number', 117 | format: 'number', 118 | label: 'Number', 119 | conditional: 'check', 120 | defaultValue: 1, 121 | }, 122 | { 123 | type: FieldType.shortText, 124 | name: 'header', 125 | label: 'Unique page header', 126 | placeholder: 'Selected startups for...', 127 | maxCharacters: 100, 128 | }, 129 | { 130 | type: FieldType.paragraph, 131 | name: 'textarea', 132 | label: 'Tell me more', 133 | }, 134 | { 135 | type: FieldType.contentSubHeader, 136 | label: 'Sub-Header', 137 | name: '_contentSubHeader_1', 138 | }, 139 | { 140 | type: FieldType.singleSelect, 141 | name: 'singleSelect', 142 | label: 'Who is the CEO?', 143 | options: ['Option 1', 'Option 2', 'Option 3', 'Option 4'], 144 | }, 145 | { 146 | type: FieldType.multiSelect, 147 | name: 'multiSelect', 148 | label: 'Who are team members?', 149 | options: ['Option 1', 'Option 2', 'Option 3', 'Option 4'], 150 | }, 151 | { 152 | type: FieldType.multiSelect, 153 | name: 'multiSelect single', 154 | label: 'Who is CEO of Facebook?', 155 | options: ['Option 1', 'Option 2', 'Option 3', 'Option 4'], 156 | multiple: false, 157 | }, 158 | { 159 | type: FieldType.contentSubHeader, 160 | label: 'Sub-Header 2', 161 | name: '_contentSubHeader_2', 162 | }, 163 | { 164 | type: FieldType.contentParagraph, 165 | label: 'Paragraph text', 166 | name: '_contentParagraph_1', 167 | }, 168 | { 169 | type: FieldType.checkbox, 170 | name: 'checkbox', 171 | label: 'I am not a robot', 172 | }, 173 | { 174 | type: FieldType.radio, 175 | name: 'radio', 176 | label: 'Highest education level?', 177 | options: ['Option 1', 'Option 2', 'Option 3', 'Option 4'], 178 | }, 179 | { 180 | type: FieldType.slider, 181 | name: 'slider', 182 | label: 'Your age', 183 | }, 184 | { 185 | type: FieldType.list, 186 | name: 'textMulti', 187 | label: 'Previous employers', 188 | }, 189 | { 190 | type: FieldType.color, 191 | name: 'color', 192 | label: 'Preferred color for your shirt?', 193 | }, 194 | { 195 | type: FieldType.score, 196 | name: 'score', 197 | label: 'How likely are you to recommend us to a friend or a colleague?', 198 | minLabel: 'Not at all likely', 199 | maxLabel: 'Likely', 200 | }, 201 | { 202 | type: FieldType.hidden, 203 | name: 'hidden', 204 | defaultValue: 'PERSISTENT VALUE', 205 | disablePadding: true, 206 | }, 207 | ]; 208 | 209 | const additionalFields = [ 210 | { 211 | type: FieldType.shortText, 212 | name: 'shortTextWithDefaultValue', 213 | label: 'Short Text with Default Value', 214 | defaultValue: 'DEFAULT VALUE', 215 | }, 216 | { 217 | type: FieldType.checkbox, 218 | name: 'checkboxWithDefaultValue', 219 | label: 'Checkbox with Default Value', 220 | defaultValue: true, 221 | }, 222 | ]; 223 | 224 | const App = () => { 225 | const [values, setValues] = React.useState({ 226 | number: 123, 227 | multiSelect: ['Option 2'], 228 | meetingLink: 'https://example.com', 229 | }); 230 | const [showAdditionalFields, setShowAdditionalFields] = React.useState(false); 231 | 232 | return ( 233 | 234 | 235 | 236 | {}} 239 | title="Form Dialog" 240 | fields={ 241 | showAdditionalFields ? [...additionalFields, ...fields] : fields 242 | } 243 | values={values} 244 | onSubmit={(data) => { 245 | console.log('data', data); 246 | setValues(data); 247 | }} 248 | // customActions={ 249 | // <> 250 | // 251 | // 252 | // } 253 | formHeader={ 254 | 262 | } 263 | UseFormProps={{ mode: 'onTouched' }} 264 | /> 265 | 266 | 267 | ); 268 | }; 269 | 270 | ReactDOM.render(, document.getElementById('root')); 271 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "start:cacheless": "rm -rf .parcel-cache && yarn start", 9 | "build": "parcel index.html --dist-dir ../docs --public-url ./" 10 | }, 11 | "dependencies": { 12 | "@types/hoist-non-react-statics": ">= 3.3.1", 13 | "@types/node": ">= 12", 14 | "react": "^17.0.2", 15 | "react-dnd": "^14.0.4", 16 | "react-dnd-html5-backend": "^14.0.2", 17 | "react-dom": "^17.0.0", 18 | "yup": "^0.32.9" 19 | }, 20 | "alias": { 21 | "@mui/material": "../node_modules/@mui/material", 22 | "react": "../node_modules/react", 23 | "react-dom": "../node_modules/react-dom/profiling", 24 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 25 | }, 26 | "devDependencies": { 27 | "@hookform/devtools": "^3.1.0", 28 | "@types/react": "^17.0.33", 29 | "@types/react-dom": "^17.0.8", 30 | "@types/yup": "^0.29.12", 31 | "parcel": "^2.0.0", 32 | "typescript": "^4.4.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "esnext", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["esnext", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"], 18 | "importHelpers": true, 19 | "declaration": true, 20 | "strict": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "esModuleInterop": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /generate-exports.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | process.chdir('./src'); 4 | 5 | const output = []; 6 | 7 | function scanDir(dir) { 8 | const items = fs.readdirSync(dir); 9 | 10 | for (const item of items) { 11 | // This is the generated file 12 | if (item === 'index.ts' && dir === '.') continue; 13 | 14 | // Recursively scan this directory 15 | if (!item.includes('.')) scanDir(dir + '/' + item); 16 | 17 | // Export the file’s contents 18 | if (!item.includes('.ts')) continue; 19 | 20 | // Get component/file name 21 | let component = item.split('.')[0]; 22 | const path = component === 'index' ? dir : `${dir}/${component}`; 23 | if (component === 'index') component = dir.split('/').pop(); 24 | 25 | // Check if file has default export 26 | const file = fs.readFileSync(dir + '/' + item); 27 | // Check if not field index file 28 | if (file.indexOf('export default') > -1 && item.split('.')[0] !== 'index') 29 | output.push(`export { default as ${component} } from '${path}';`); 30 | 31 | output.push(`export * from '${path}';\n`); 32 | } 33 | } 34 | 35 | scanDir('.'); 36 | fs.writeFileSync('index.ts', output.join('\n')); 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.8.0", 3 | "name": "@rowy/form-builder", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "author": "Sidney Alcantara", 8 | "license": "MIT", 9 | "main": "dist/index.js", 10 | "typings": "dist/index.d.ts", 11 | "files": [ 12 | "dist", 13 | "src" 14 | ], 15 | "engines": { 16 | "node": ">=10" 17 | }, 18 | "scripts": { 19 | "start": "tsdx watch", 20 | "build": "node generate-exports && git add src/index.ts && tsdx build", 21 | "prepublishOnly": "node generate-exports && git add src/index.ts && tsdx build", 22 | "lint": "tsdx lint", 23 | "prepare": "tsdx build", 24 | "storybook": "start-storybook -p 6006", 25 | "build-storybook": "build-storybook" 26 | }, 27 | "peerDependencies": { 28 | "@mui/icons-material": "^5.4.1", 29 | "@mui/material": "^5.4.1", 30 | "@mui/x-date-pickers": "^5.0.9", 31 | "@rowy/multiselect": "^0.2.3", 32 | "react": "^17.0.2" 33 | }, 34 | "husky": { 35 | "hooks": { 36 | "pre-commit": "pretty-quick --staged; tsdx lint" 37 | } 38 | }, 39 | "prettier": { 40 | "printWidth": 80, 41 | "semi": true, 42 | "singleQuote": true, 43 | "trailingComma": "es5" 44 | }, 45 | "module": "dist/form-builder.esm.js", 46 | "devDependencies": { 47 | "@babel/core": "^7.14.6", 48 | "@emotion/react": "^11.4.0", 49 | "@emotion/styled": "^11.3.0", 50 | "@mui/icons-material": "^5.4.1", 51 | "@mui/material": "^5.4.1", 52 | "@mui/x-date-pickers": "^5.0.9", 53 | "@rowy/multiselect": "^0.4.1", 54 | "@storybook/addon-actions": "^6.3.8", 55 | "@storybook/addon-docs": "^6.3.8", 56 | "@storybook/addon-info": "^5.3.21", 57 | "@storybook/addon-links": "^6.3.8", 58 | "@storybook/addons": "^6.3.8", 59 | "@storybook/react": "^6.3.8", 60 | "@types/dompurify": "^2.2.2", 61 | "@types/react": "^17.0.13", 62 | "@types/react-color": "^3.0.4", 63 | "@types/react-dom": "^17.0.8", 64 | "@types/yup": "^0.29.12", 65 | "babel-loader": "^8.2.2", 66 | "eslint-plugin-prettier": "^3", 67 | "husky": "^4.2.5", 68 | "react": "^17", 69 | "react-docgen-typescript-loader": "^3.7.2", 70 | "react-dom": "^17.0.2", 71 | "react-is": "^17.0.2", 72 | "ts-loader": "^9.2.3", 73 | "tsdx": "^0.14.1", 74 | "tslib": "^2.3.0", 75 | "tss-react": "3", 76 | "typescript": "^4.3.5" 77 | }, 78 | "dependencies": { 79 | "@hookform/resolvers": "^2.6.0", 80 | "@types/lodash-es": "^4.17.6", 81 | "array-move": "^3.0.1", 82 | "date-fns": "^2.22.1", 83 | "dompurify": "^2.2.9", 84 | "lodash-es": "^4.17.21", 85 | "mdi-material-ui": "^7.2.0", 86 | "react-color-palette": "^6.2.0", 87 | "react-dnd": "^14.0.2", 88 | "react-dnd-html5-backend": "^14.0.0", 89 | "react-element-scroll-hook": "^1.1.0", 90 | "react-hook-form": "^7.10.0", 91 | "use-debounce": "^8.0.0", 92 | "yup": "^0.32.9" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/AutoSave.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Control, useWatch, useFormState } from 'react-hook-form'; 3 | import { useDebounce } from 'use-debounce'; 4 | import _isEqual from 'lodash-es/isEqual'; 5 | import _omitBy from 'lodash-es/omitBy'; 6 | import _isUndefined from 'lodash-es/isUndefined'; 7 | import { diffChanges } from './utils'; 8 | 9 | import { IFormProps } from './Form'; 10 | 11 | export interface IAutoSaveProps { 12 | control: Control; 13 | defaultValues: NonNullable; 14 | onSubmit: IFormProps['onSubmit']; 15 | } 16 | 17 | export default function AutoSave({ 18 | control, 19 | defaultValues, 20 | onSubmit, 21 | }: IAutoSaveProps) { 22 | const values = useWatch({ control }); 23 | const { errors } = useFormState({ control }); 24 | 25 | const [debouncedValues] = useDebounce(values, 1000, { 26 | equalityFn: _isEqual, 27 | }); 28 | 29 | useEffect(() => { 30 | // - Update only fields that changed 31 | // - Remove values with errors 32 | // - Remove undefined value to prevent Firestore crash 33 | const newValues = _omitBy( 34 | diffChanges(defaultValues, debouncedValues), 35 | (value, name) => _isUndefined(value) || name in errors 36 | ); 37 | 38 | if (Object.keys(newValues).length > 0) { 39 | console.log('SUBMIT', newValues, errors); 40 | onSubmit(newValues); 41 | } 42 | }, [debouncedValues]); 43 | 44 | return null; 45 | } 46 | -------------------------------------------------------------------------------- /src/FieldAssistiveText.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import DOMPurify from 'dompurify'; 3 | 4 | import { FormHelperText, FormHelperTextProps } from '@mui/material'; 5 | 6 | export interface IFieldAssistiveTextProps 7 | extends Omit { 8 | disabled: boolean; 9 | } 10 | 11 | export default function FieldAssistiveText({ 12 | children, 13 | ...props 14 | }: IFieldAssistiveTextProps) { 15 | if (!children) return null; 16 | 17 | const sanitizedChildren = 18 | typeof children === 'string' ? DOMPurify.sanitize(children) : null; 19 | 20 | if (sanitizedChildren) 21 | return ( 22 | 28 | ); 29 | 30 | return ( 31 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/FieldErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { FormHelperText, FormHelperTextProps } from '@mui/material'; 4 | 5 | export default function FieldErrorMessage(props: FormHelperTextProps) { 6 | if (!props.children) return null; 7 | 8 | return ( 9 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/FieldLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { InputLabel, InputLabelProps } from '@mui/material'; 4 | 5 | export interface IFieldLabelProps 6 | extends Omit { 7 | error: boolean; 8 | disabled: boolean; 9 | required: boolean; 10 | } 11 | 12 | export default function FieldLabel(props: IFieldLabelProps) { 13 | return ( 14 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/FieldSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Skeleton, SkeletonProps } from '@mui/material'; 3 | 4 | export default function FieldSkeleton(props: SkeletonProps) { 5 | return ( 6 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/FieldWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { Controller } from 'react-hook-form'; 3 | 4 | import { Grid } from '@mui/material'; 5 | import FieldSkeleton from './FieldSkeleton'; 6 | 7 | import { getFieldProp } from './fields'; 8 | 9 | import { IFormFieldsProps } from './FormFields'; 10 | import { Field, CustomComponent } from './types'; 11 | import { controllerRenderPropsStub } from './utils'; 12 | 13 | export interface IFieldWrapperProps 14 | extends Field, 15 | Omit { 16 | index: number; 17 | disabledConditional?: boolean; 18 | } 19 | 20 | /** 21 | * Finds the corresponding component for the field type and wraps it with 22 | * Controller. 23 | */ 24 | export default function FieldWrapper({ 25 | control, 26 | name, 27 | label, 28 | type, 29 | customComponents, 30 | gridCols = 12, 31 | disablePadding, 32 | disablePaddingTop, 33 | disabledConditional, 34 | defaultValue: defaultValueProp, 35 | setOmittedFields, 36 | ...props 37 | }: IFieldWrapperProps) { 38 | if (!type) { 39 | console.error(`Invalid field type: ${type}`, props); 40 | return null; 41 | } 42 | 43 | let fieldComponent: CustomComponent; 44 | // Pass defaultValue into the Controller for conditionally displayed fields 45 | let defaultValue: any = defaultValueProp; 46 | 47 | // Try to get fieldComponent from customComponents list 48 | if ( 49 | !!customComponents && 50 | Object.keys(customComponents).length > 0 && 51 | type in customComponents 52 | ) { 53 | fieldComponent = customComponents[type].component; 54 | 55 | if (defaultValue === undefined) 56 | defaultValue = customComponents[type].defaultValue; 57 | } 58 | // If not found in customComponents, try to get it from the built-in components 59 | else { 60 | fieldComponent = getFieldProp('component', type); 61 | 62 | if (defaultValue === undefined) 63 | defaultValue = getFieldProp('defaultValue', type); 64 | 65 | // If not found in either, don’t display anything 66 | if (!fieldComponent) { 67 | console.error(`No matching field component for \`${type}\``); 68 | return null; 69 | } 70 | } 71 | 72 | if (!name) return null; 73 | 74 | const gridProps = 75 | typeof gridCols === 'number' || 76 | typeof gridCols === 'string' || 77 | typeof gridCols === 'boolean' 78 | ? { xs: gridCols } 79 | : gridCols; 80 | 81 | const styleOverrides = disablePadding 82 | ? { padding: 0 } 83 | : disablePaddingTop 84 | ? { paddingTop: 0 } 85 | : {}; 86 | 87 | // If it’s a content field, don’t wrap with Controller 88 | if (getFieldProp('group', type) === 'content') 89 | return ( 90 | 97 | }> 98 | {React.createElement(fieldComponent, { 99 | ...props, 100 | // Stub Controller render props 101 | ...controllerRenderPropsStub, 102 | disabled: true, 103 | name: name!, // Fix TypeScript error 104 | label: label!, // Fix TypeScript error 105 | })} 106 | 107 | 108 | ); 109 | 110 | // If it’s a conditional field and the user hasn’t ticked, make sure the 111 | // Controller doesn’t register the field and there is no value for this field 112 | if (disabledConditional) 113 | return ( 114 | 121 | }> 122 | {React.createElement(fieldComponent, { 123 | ...props, 124 | // Stub Controller render props 125 | ...controllerRenderPropsStub, 126 | disabled: true, 127 | name: name!, // Fix TypeScript error 128 | label: label!, // Fix TypeScript error 129 | })} 130 | 131 | 132 | ); 133 | 134 | return ( 135 | 142 | }> 143 | 147 | React.createElement(fieldComponent, { 148 | ...props, 149 | ...renderProps, 150 | name: name!, // Fix TypeScript error 151 | label: label!, // Fix TypeScript error 152 | errorMessage: renderProps.fieldState.error?.message, 153 | }) 154 | } 155 | defaultValue={defaultValue} 156 | /> 157 | 158 | 159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /src/Fields/Checkbox/CheckboxComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { FormControlLabel, Checkbox, CheckboxProps } from '@mui/material'; 5 | 6 | import FieldErrorMessage from '../../FieldErrorMessage'; 7 | import FieldAssistiveText from '../../FieldAssistiveText'; 8 | 9 | export interface ICheckboxComponentProps 10 | extends IFieldComponentProps, 11 | Omit< 12 | CheckboxProps, 13 | 'name' | 'onChange' | 'checked' | 'ref' | 'value' | 'onBlur' 14 | > {} 15 | 16 | export default function CheckboxComponent({ 17 | field: { onChange, onBlur, value, ref }, 18 | fieldState, 19 | formState, 20 | 21 | name, 22 | useFormMethods, 23 | 24 | label, 25 | errorMessage, 26 | assistiveText, 27 | 28 | required, 29 | 30 | ...props 31 | }: ICheckboxComponentProps) { 32 | return ( 33 | { 39 | onChange(e.target.checked); 40 | onBlur(); 41 | }} 42 | inputProps={ 43 | { 44 | 'data-type': 'checkbox', 45 | 'data-label': label ?? '', 46 | } as any 47 | } 48 | sx={[ 49 | { 50 | '.MuiFormControlLabel-root:not(.Mui-disabled):hover &': { 51 | bgcolor: 'action.hover', 52 | }, 53 | }, 54 | ...(Array.isArray(props.sx) 55 | ? props.sx 56 | : props.sx 57 | ? [props.sx] 58 | : []), 59 | ]} 60 | inputRef={ref} 61 | /> 62 | } 63 | onBlur={onBlur} 64 | label={ 65 | <> 66 | {label} 67 | {required && <> *} 68 | 69 | {errorMessage} 70 | 71 | {assistiveText} 72 | 73 | 74 | } 75 | sx={{ mr: 0, display: 'flex' }} 76 | /> 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/Fields/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import CheckboxMarked from 'mdi-material-ui/CheckboxMarked'; 6 | 7 | const Component = lazy( 8 | () => 9 | import('./CheckboxComponent') /* webpackChunkName: FormBuilder-Checkbox */ 10 | ); 11 | 12 | export const CheckboxConfig: IFieldConfig = { 13 | type: FieldType.checkbox, 14 | name: 'Checkbox', 15 | group: 'input', 16 | icon: , 17 | dataType: 'boolean', 18 | defaultValue: false, 19 | component: Component, 20 | settings: [], 21 | validation: (config: Record) => { 22 | const validation: any[][] = [['boolean']]; 23 | 24 | if (config.required === true) 25 | validation.push(['oneOf', [true], `Please tick the box`]); 26 | 27 | return validation; 28 | }, 29 | }; 30 | export default CheckboxConfig; 31 | -------------------------------------------------------------------------------- /src/Fields/Color/ColorComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | import { ColorPicker, toColor } from 'react-color-palette'; 4 | import 'react-color-palette/lib/css/styles.css'; 5 | 6 | import { 7 | TextField, 8 | TextFieldProps, 9 | InputAdornment, 10 | Box, 11 | IconButton, 12 | Popover, 13 | } from '@mui/material'; 14 | import PaletteIcon from '@mui/icons-material/Palette'; 15 | 16 | import FieldAssistiveText from '../../FieldAssistiveText'; 17 | 18 | export interface IColorComponentProps extends IFieldComponentProps { 19 | enableAlpha?: boolean; 20 | TextFieldProps?: Partial; 21 | } 22 | 23 | export default function ColorComponent({ 24 | field: { onChange, onBlur, value, ref }, 25 | 26 | label, 27 | errorMessage, 28 | assistiveText, 29 | 30 | required, 31 | disabled, 32 | 33 | enableAlpha, 34 | TextFieldProps, 35 | }: IColorComponentProps) { 36 | const anchorEl = useRef(null); 37 | const [open, setOpen] = useState(false); 38 | const handleOpen = () => setOpen(true); 39 | 40 | return ( 41 | <> 42 | 47 | 53 | `0 0 0 1px ${theme.palette.action.disabled} inset`, 54 | borderRadius: '50%', 55 | 56 | backgroundColor: value.hex, 57 | }} 58 | /> 59 | 60 | ), 61 | endAdornment: ( 62 | 63 | 69 | 70 | 71 | 72 | ), 73 | }} 74 | onClick={handleOpen} 75 | {...TextFieldProps} 76 | value={value?.hex} 77 | label={label} 78 | inputProps={{ readOnly: true, required: false }} 79 | error={!!errorMessage} 80 | helperText={ 81 | (errorMessage || assistiveText) && ( 82 | <> 83 | {errorMessage} 84 | 85 | 86 | {assistiveText} 87 | 88 | 89 | ) 90 | } 91 | required={required} 92 | disabled={disabled} 93 | ref={anchorEl} 94 | inputRef={ref} 95 | /> 96 | {!disabled && anchorEl.current && ( 97 | { 101 | setOpen(false); 102 | onBlur(); 103 | }} 104 | PaperProps={{ 'data-type': 'color-picker' } as any} 105 | anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} 106 | transformOrigin={{ vertical: 'top', horizontal: 'center' }} 107 | > 108 | 115 | 116 | )} 117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/Fields/Color/ColorSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ColorSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.checkbox, 7 | name: 'enableAlpha', 8 | label: 'Enable alpha channel (user can add semi-transparent colors)', 9 | defaultValue: false, 10 | }, 11 | ]; 12 | 13 | export default ColorSettings; 14 | -------------------------------------------------------------------------------- /src/Fields/Color/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import Palette from 'mdi-material-ui/Palette'; 6 | 7 | import Settings from './ColorSettings'; 8 | const Component = lazy( 9 | () => import('./ColorComponent') /* webpackChunkName: FormBuilder-Color */ 10 | ); 11 | 12 | export const ColorConfig: IFieldConfig = { 13 | type: FieldType.color, 14 | name: 'Color', 15 | group: 'input', 16 | icon: , 17 | dataType: 'Record', 18 | defaultValue: null, 19 | component: Component, 20 | settings: Settings, 21 | validation: () => [['object'], ['nullable']], 22 | }; 23 | export default ColorConfig; 24 | -------------------------------------------------------------------------------- /src/Fields/ContentHeader/ContentHeaderComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { Box, Typography, TypographyProps, Divider } from '@mui/material'; 5 | 6 | export interface IContentHeaderComponentProps 7 | extends IFieldComponentProps, 8 | Partial> {} 9 | 10 | export default function ContentHeaderComponent({ 11 | field, 12 | fieldState, 13 | formState, 14 | 15 | index, 16 | label, 17 | children, 18 | className, 19 | 20 | disabled, 21 | errorMessage, 22 | name, 23 | useFormMethods, 24 | ...props 25 | }: IContentHeaderComponentProps) { 26 | return ( 27 | 41 | 46 | {children ?? label} 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/Fields/ContentHeader/ContentHeaderSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ContentHeaderSettings: IFieldConfig['settings'] = [ 5 | { 6 | name: 'label', 7 | label: 'Header', 8 | type: FieldType.shortText, 9 | required: true, 10 | defaultValue: '', 11 | }, 12 | ]; 13 | 14 | export default ContentHeaderSettings; 15 | -------------------------------------------------------------------------------- /src/Fields/ContentHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import FormatHeader1 from 'mdi-material-ui/FormatHeader1'; 6 | 7 | import Settings from './ContentHeaderSettings'; 8 | const Component = lazy( 9 | () => 10 | import( 11 | './ContentHeaderComponent' 12 | ) /* webpackChunkName: FormBuilder-ContentHeader */ 13 | ); 14 | 15 | export const ContentHeaderConfig: IFieldConfig = { 16 | type: FieldType.contentHeader, 17 | name: 'Header', 18 | group: 'content', 19 | icon: , 20 | dataType: 'undefined', 21 | defaultValue: undefined, 22 | component: Component, 23 | settings: Settings, 24 | validation: () => [], 25 | }; 26 | export default ContentHeaderConfig; 27 | -------------------------------------------------------------------------------- /src/Fields/ContentImage/ContentImageComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | export interface IContentImageComponentProps 5 | extends IFieldComponentProps, 6 | Omit, 'src'> { 7 | src: string | { downloadURL: string }[]; 8 | } 9 | 10 | export default function ContentImageComponent({ 11 | field, 12 | fieldState, 13 | formState, 14 | 15 | index, 16 | label, 17 | children, 18 | 19 | disabled, 20 | errorMessage, 21 | name, 22 | useFormMethods, 23 | 24 | src, 25 | alt, 26 | ...props 27 | }: IContentImageComponentProps) { 28 | if (!src || (Array.isArray(src) && (src.length === 0 || !src[0].downloadURL))) 29 | return null; 30 | 31 | return ( 32 | {alt} 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/Fields/ContentImage/ContentImageSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ContentImageSettings: IFieldConfig['settings'] = [ 5 | { 6 | name: 'src', 7 | label: 'Image Source', 8 | type: 'image', 9 | required: true, 10 | defaultValue: undefined, 11 | }, 12 | { 13 | name: 'alt', 14 | label: 'Alt Text', 15 | type: FieldType.shortText, 16 | required: true, 17 | defaultValue: '', 18 | assistiveText: 19 | 'Learn more about alt text', 20 | }, 21 | ]; 22 | 23 | export default ContentImageSettings; 24 | -------------------------------------------------------------------------------- /src/Fields/ContentImage/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import Image from 'mdi-material-ui/Image'; 6 | 7 | import Settings from './ContentImageSettings'; 8 | const Component = lazy( 9 | () => 10 | import( 11 | './ContentImageComponent' 12 | ) /* webpackChunkName: FormBuilder-ContentImage */ 13 | ); 14 | 15 | export const ContentImageConfig: IFieldConfig = { 16 | type: FieldType.contentImage, 17 | name: 'Image', 18 | group: 'content', 19 | icon: , 20 | dataType: 'undefined', 21 | defaultValue: undefined, 22 | component: Component as any, 23 | settings: Settings, 24 | validation: () => [], 25 | }; 26 | export default ContentImageConfig; 27 | -------------------------------------------------------------------------------- /src/Fields/ContentParagraph/ContentParagraphComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | import DOMPurify from 'dompurify'; 4 | 5 | import { Typography, TypographyProps } from '@mui/material'; 6 | 7 | const rootStyles = { mb: -1.5, whiteSpace: 'pre-line', cursor: 'default' }; 8 | 9 | export interface IContentParagraphComponentProps 10 | extends IFieldComponentProps, 11 | Partial> {} 12 | 13 | export default function ContentParagraphComponent({ 14 | field, 15 | fieldState, 16 | formState, 17 | 18 | index, 19 | label, 20 | children, 21 | className, 22 | 23 | disabled, 24 | errorMessage, 25 | name, 26 | useFormMethods, 27 | ...props 28 | }: IContentParagraphComponentProps) { 29 | if (children) 30 | return ( 31 | 40 | {children} 41 | 42 | ); 43 | 44 | const renderedLabel = 45 | typeof label === 'string' ? DOMPurify.sanitize(label) : null; 46 | 47 | if (renderedLabel) 48 | return ( 49 | 59 | ); 60 | 61 | return ( 62 | 71 | {label} 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/Fields/ContentParagraph/ContentParagraphSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ContentParagraphSettings: IFieldConfig['settings'] = [ 5 | { 6 | name: 'label', 7 | label: 'Paragraph', 8 | type: FieldType.paragraph, 9 | required: true, 10 | defaultValue: '', 11 | }, 12 | ]; 13 | 14 | export default ContentParagraphSettings; 15 | -------------------------------------------------------------------------------- /src/Fields/ContentParagraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import Text from 'mdi-material-ui/Text'; 6 | 7 | import Settings from './ContentParagraphSettings'; 8 | const Component = lazy( 9 | () => 10 | import( 11 | './ContentParagraphComponent' 12 | ) /* webpackChunkName: FormBuilder-ContentParagraph */ 13 | ); 14 | 15 | export const ContentParagraphConfig: IFieldConfig = { 16 | type: FieldType.contentParagraph, 17 | name: 'Paragraph', 18 | group: 'content', 19 | icon: , 20 | dataType: 'undefined', 21 | defaultValue: undefined, 22 | component: Component, 23 | settings: Settings, 24 | validation: () => [], 25 | }; 26 | export default ContentParagraphConfig; 27 | -------------------------------------------------------------------------------- /src/Fields/ContentSubHeader/ContentSubHeaderComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { Typography, TypographyProps } from '@mui/material'; 5 | 6 | export interface IContentSubHeaderComponentProps 7 | extends IFieldComponentProps, 8 | Partial> {} 9 | 10 | export default function ContentSubHeaderComponent({ 11 | field, 12 | fieldState, 13 | formState, 14 | 15 | index, 16 | label, 17 | children, 18 | className, 19 | 20 | disabled, 21 | errorMessage, 22 | name, 23 | useFormMethods, 24 | ...props 25 | }: IContentSubHeaderComponentProps) { 26 | return ( 27 | 38 | {children ?? label} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/Fields/ContentSubHeader/ContentSubHeaderSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ContentSubHeaderSettings: IFieldConfig['settings'] = [ 5 | { 6 | name: 'label', 7 | label: 'Sub-Header', 8 | type: FieldType.shortText, 9 | required: true, 10 | defaultValue: '', 11 | }, 12 | ]; 13 | 14 | export default ContentSubHeaderSettings; 15 | -------------------------------------------------------------------------------- /src/Fields/ContentSubHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import FormatHeader2 from 'mdi-material-ui/FormatHeader2'; 6 | 7 | import Settings from './ContentSubHeaderSettings'; 8 | const Component = lazy( 9 | () => 10 | import( 11 | './ContentSubHeaderComponent' 12 | ) /* webpackChunkName: FormBuilder-ContentSubHeader */ 13 | ); 14 | 15 | export const ContentSubHeaderConfig: IFieldConfig = { 16 | type: FieldType.contentSubHeader, 17 | name: 'Sub-Header', 18 | group: 'content', 19 | icon: , 20 | dataType: 'undefined', 21 | defaultValue: undefined, 22 | component: Component, 23 | settings: Settings, 24 | validation: () => [], 25 | }; 26 | export default ContentSubHeaderConfig; 27 | -------------------------------------------------------------------------------- /src/Fields/Date/DateComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { 5 | LocalizationProvider, 6 | DatePicker, 7 | DatePickerProps, 8 | } from '@mui/x-date-pickers'; 9 | import { TextField, TextFieldProps } from '@mui/material'; 10 | import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; 11 | 12 | import FieldAssistiveText from '../../FieldAssistiveText'; 13 | 14 | export interface IDateComponentProps 15 | extends IFieldComponentProps, 16 | Omit< 17 | DatePickerProps, 18 | 'label' | 'name' | 'onChange' | 'value' | 'ref' 19 | > { 20 | TextFieldProps: TextFieldProps; 21 | } 22 | 23 | export default function DateComponent({ 24 | field: { onChange, onBlur, value, ref }, 25 | fieldState, 26 | formState, 27 | 28 | name, 29 | useFormMethods, 30 | 31 | errorMessage, 32 | assistiveText, 33 | 34 | TextFieldProps, 35 | ...props 36 | }: IDateComponentProps) { 37 | let transformedValue: any = null; 38 | if (value && 'toDate' in value) transformedValue = value.toDate(); 39 | else if (value !== undefined) transformedValue = value; 40 | 41 | return ( 42 | 43 | ( 54 | 64 | {errorMessage} 65 | 66 | 70 | {assistiveText} 71 | 72 | 73 | ) 74 | } 75 | data-type="date" 76 | data-label={props.label ?? ''} 77 | inputProps={{ 78 | ...props.inputProps, 79 | required: false, 80 | }} 81 | sx={{ 82 | '& .MuiInputBase-input': { fontVariantNumeric: 'tabular-nums' }, 83 | ...TextFieldProps?.sx, 84 | }} 85 | /> 86 | )} 87 | /> 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/Fields/Date/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import Calendar from 'mdi-material-ui/Calendar'; 6 | 7 | const Component = lazy( 8 | () => import('./DateComponent') /* webpackChunkName: FormBuilder-Date */ 9 | ); 10 | 11 | export const DateConfig: IFieldConfig = { 12 | type: FieldType.date, 13 | name: 'Date', 14 | group: 'input', 15 | icon: , 16 | dataType: 'Date | null', 17 | defaultValue: null, 18 | component: Component as any, 19 | settings: [], 20 | validation: () => [ 21 | ['date'], 22 | ['typeError', 'Please enter a valid date'], 23 | ['nullable'], 24 | ], 25 | }; 26 | export default DateConfig; 27 | -------------------------------------------------------------------------------- /src/Fields/DateTime/DateTimeComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { 5 | LocalizationProvider, 6 | DateTimePicker, 7 | DateTimePickerProps, 8 | } from '@mui/x-date-pickers'; 9 | import { TextField, TextFieldProps } from '@mui/material'; 10 | import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; 11 | import AccessTimeIcon from '@mui/icons-material/AccessTime'; 12 | 13 | import FieldAssistiveText from '../../FieldAssistiveText'; 14 | 15 | export interface IDateTimeComponentProps 16 | extends IFieldComponentProps, 17 | Omit< 18 | DateTimePickerProps, 19 | 'label' | 'name' | 'onChange' | 'value' | 'ref' 20 | > { 21 | TextFieldProps: TextFieldProps; 22 | } 23 | 24 | export default function DateTimeComponent({ 25 | field: { onChange, onBlur, value, ref }, 26 | fieldState, 27 | formState, 28 | 29 | name, 30 | useFormMethods, 31 | 32 | errorMessage, 33 | assistiveText, 34 | 35 | TextFieldProps, 36 | ...props 37 | }: IDateTimeComponentProps) { 38 | let transformedValue: any = null; 39 | if (value && 'toDate' in value) transformedValue = value.toDate(); 40 | else if (value !== undefined) transformedValue = value; 41 | 42 | return ( 43 | 44 | ( 56 | 66 | {errorMessage} 67 | 68 | 72 | {assistiveText} 73 | 74 | 75 | ) 76 | } 77 | data-type="date-time" 78 | data-label={props.label ?? ''} 79 | inputProps={{ 80 | ...props.inputProps, 81 | required: false, 82 | }} 83 | sx={{ 84 | '& .MuiInputBase-input': { fontVariantNumeric: 'tabular-nums' }, 85 | ...TextFieldProps?.sx, 86 | }} 87 | /> 88 | )} 89 | /> 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/Fields/DateTime/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import CalendarClock from 'mdi-material-ui/CalendarClock'; 6 | 7 | const Component = lazy( 8 | () => 9 | import('./DateTimeComponent') /* webpackChunkName: FormBuilder-DateTime */ 10 | ); 11 | 12 | export const DateTimeConfig: IFieldConfig = { 13 | type: FieldType.dateTime, 14 | name: 'Time & Date', 15 | group: 'input', 16 | icon: , 17 | dataType: 'Date | null', 18 | defaultValue: null, 19 | component: Component as any, 20 | settings: [], 21 | validation: () => [ 22 | ['date'], 23 | ['typeError', 'Please enter a valid date'], 24 | ['nullable'], 25 | ], 26 | }; 27 | export default DateTimeConfig; 28 | -------------------------------------------------------------------------------- /src/Fields/Hidden/HiddenComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | export default function HiddenComponent({ 5 | field: { value, ref }, 6 | name, 7 | }: IFieldComponentProps) { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /src/Fields/Hidden/HiddenSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const HiddenSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.shortText, 7 | name: 'defaultValue', 8 | label: 'Persistent Value', 9 | required: true, 10 | defaultValue: '', 11 | assistiveText: 12 | 'This value will always be submitted as part of the form. It cannot be edited by the user.', 13 | }, 14 | { 15 | type: FieldType.checkbox, 16 | name: 'disablePadding', 17 | label: 'Disable padding', 18 | defaultValue: true, 19 | required: true, 20 | }, 21 | ]; 22 | 23 | export default HiddenSettings; 24 | -------------------------------------------------------------------------------- /src/Fields/Hidden/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import EyeOff from 'mdi-material-ui/EyeOff'; 6 | 7 | import Settings from './HiddenSettings'; 8 | const Component = lazy( 9 | () => import('./HiddenComponent') /* webpackChunkName: FormBuilder-Hidden */ 10 | ); 11 | 12 | export const HiddenConfig: IFieldConfig = { 13 | type: FieldType.hidden, 14 | name: 'Hidden', 15 | group: 'input', 16 | icon: , 17 | dataType: 'string', 18 | defaultValue: '', 19 | component: Component, 20 | settings: Settings, 21 | validation: () => [], 22 | }; 23 | export default HiddenConfig; 24 | -------------------------------------------------------------------------------- /src/Fields/List/ListComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import arrayMove from 'array-move'; 3 | import { IFieldComponentProps } from '../../types'; 4 | import { DndProvider } from 'react-dnd'; 5 | import { HTML5Backend } from 'react-dnd-html5-backend'; 6 | 7 | import { FormControl, Button, ButtonProps } from '@mui/material'; 8 | import AddCircleIcon from '@mui/icons-material/AddCircle'; 9 | 10 | import ListItem from './ListItem'; 11 | 12 | import FieldLabel from '../../FieldLabel'; 13 | import FieldErrorMessage from '../../FieldErrorMessage'; 14 | import FieldAssistiveText from '../../FieldAssistiveText'; 15 | 16 | export interface IListComponentProps extends IFieldComponentProps { 17 | itemLabel?: string; 18 | placeholder?: string; 19 | addButtonProps?: Partial; 20 | } 21 | 22 | export default function ListComponent({ 23 | field: { onChange, value: valueProp, ref }, 24 | 25 | name, 26 | useFormMethods, 27 | 28 | label, 29 | errorMessage, 30 | assistiveText, 31 | 32 | required, 33 | disabled, 34 | 35 | itemLabel = 'Item', 36 | placeholder, 37 | addButtonProps = {}, 38 | }: IListComponentProps) { 39 | const value: string[] = Array.isArray(valueProp) ? valueProp : []; 40 | const add = () => onChange([...value, '']); 41 | 42 | const edit = (index: number) => (item: string) => { 43 | const newValue = [...useFormMethods.getValues(name)]; 44 | newValue[index] = item; 45 | onChange(newValue); 46 | }; 47 | 48 | const swap = (fromIndex: number, toIndex: number) => { 49 | const newValue = arrayMove( 50 | useFormMethods.getValues(name), 51 | fromIndex, 52 | toIndex 53 | ); 54 | onChange(newValue); 55 | }; 56 | 57 | const remove = (index: number) => () => { 58 | const newValue = [...useFormMethods.getValues(name)]; 59 | newValue.splice(index, 1); 60 | onChange(newValue); 61 | }; 62 | 63 | return ( 64 | 74 | 79 | {label} 80 | 81 | 82 | 83 | {value.map((item, index) => ( 84 | 96 | ))} 97 | 98 | 99 |
100 | 121 |
122 | 123 | {errorMessage} 124 | 125 | {assistiveText} 126 | 127 |
128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /src/Fields/List/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { useDrag, useDrop } from 'react-dnd'; 3 | 4 | import { Grid, TextField, IconButton } from '@mui/material'; 5 | import DragHandleIcon from '@mui/icons-material/DragHandle'; 6 | import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'; 7 | 8 | export interface IListItemProps { 9 | name: string; 10 | index: number; 11 | item: string; 12 | 13 | edit: (item: string) => void; 14 | swap: (fromIndex: number, toIndex: number) => void; 15 | remove: () => void; 16 | 17 | itemLabel?: string; 18 | placeholder?: string; 19 | disabled?: boolean; 20 | } 21 | 22 | export const MemoizedListItem = memo( 23 | function ListItem({ 24 | name, 25 | index, 26 | item, 27 | 28 | edit, 29 | swap, 30 | remove, 31 | 32 | itemLabel, 33 | placeholder, 34 | disabled, 35 | }: IListItemProps) { 36 | const [, drag, dragPreview] = useDrag(() => ({ 37 | type: name, 38 | item: { index }, 39 | collect: (monitor) => ({ 40 | isDragging: !!monitor.isDragging(), 41 | }), 42 | })); 43 | 44 | const [{ isOver }, drop] = useDrop( 45 | () => ({ 46 | accept: name, 47 | drop: ({ index: fromIndex }: any) => swap(fromIndex, index), 48 | collect: (monitor) => ({ 49 | isOver: !!monitor.isOver(), 50 | }), 51 | }), 52 | [index] 53 | ); 54 | 55 | return ( 56 |
57 | 58 | 71 | 75 | 76 | 77 | 78 | edit(e.target.value)} 85 | disabled={disabled} 86 | helperText=" " 87 | /> 88 | 89 | 90 | 91 | 98 | 99 | 100 | 101 | 102 |
103 | ); 104 | }, 105 | ({ item: prevItem, index: prevIndex }, { item, index }) => 106 | prevItem === item && prevIndex === index 107 | ); 108 | 109 | export default MemoizedListItem; 110 | -------------------------------------------------------------------------------- /src/Fields/List/ListSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ListSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.shortText, 7 | name: 'placeholder', 8 | label: 'Placeholder', 9 | defaultValue: '', 10 | }, 11 | { 12 | type: FieldType.shortText, 13 | name: 'itemLabel', 14 | label: 'Item Label', 15 | defaultValue: '', 16 | }, 17 | ]; 18 | 19 | export default ListSettings; 20 | -------------------------------------------------------------------------------- /src/Fields/List/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | import * as yup from 'yup'; 5 | 6 | import FormatListNumbered from 'mdi-material-ui/FormatListNumbered'; 7 | 8 | import Settings from './ListSettings'; 9 | const Component = lazy( 10 | () => import('./ListComponent') /* webpackChunkName: FormBuilder-List */ 11 | ); 12 | 13 | export const ListConfig: IFieldConfig = { 14 | type: FieldType.list, 15 | name: 'List', 16 | group: 'input', 17 | icon: , 18 | dataType: 'string[]', 19 | defaultValue: [], 20 | component: Component, 21 | settings: Settings, 22 | validation: (config: Record) => { 23 | const validation: any[][] = [ 24 | ['array'], 25 | ['of', yup.string().trim()], 26 | ['ensure'], 27 | ['compact'], 28 | ]; 29 | 30 | if (config.required === true) 31 | validation.push(['min', 1, `${config.label || config.name} is required`]); 32 | 33 | return validation; 34 | }, 35 | }; 36 | export default ListConfig; 37 | -------------------------------------------------------------------------------- /src/Fields/MultiSelect/MultiSelectComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | import MultiSelect, { MultiSelectProps } from '@rowy/multiselect'; 4 | 5 | import FieldAssistiveText from '../../FieldAssistiveText'; 6 | 7 | export interface IMultiSelectComponentProps 8 | extends IFieldComponentProps, 9 | Omit, 'value' | 'onChange' | 'options' | 'label'> { 10 | options: (string | { value: string; label: React.ReactNode })[]; 11 | } 12 | 13 | export default function MultiSelectComponent({ 14 | field: { onChange, onBlur, value, ref }, 15 | fieldState, 16 | formState, 17 | 18 | name, 19 | useFormMethods, 20 | 21 | errorMessage, 22 | assistiveText, 23 | 24 | options = [], 25 | ...props 26 | }: IMultiSelectComponentProps) { 27 | return ( 28 | 48 | {errorMessage} 49 | 50 | 54 | {assistiveText} 55 | 56 | 57 | ), 58 | onBlur, 59 | 'data-type': 'multi-select', 60 | 'data-label': props.label ?? '', 61 | inputRef: ref, 62 | }} 63 | /> 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/Fields/MultiSelect/MultiSelectSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const MultiSelectSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.list, 7 | name: 'options', 8 | label: 'Options', 9 | defaultValue: [], 10 | }, 11 | { 12 | type: FieldType.checkbox, 13 | name: 'searchable', 14 | label: 'User can search options', 15 | defaultValue: true, 16 | }, 17 | { 18 | type: FieldType.shortText, 19 | name: 'labelPlural', 20 | label: 'Plural Label (if searchable)', 21 | defaultValue: undefined, 22 | displayCondition: 'return values.searchable === true', 23 | }, 24 | { 25 | type: FieldType.checkbox, 26 | name: 'freeText', 27 | label: 'User can add new options', 28 | defaultValue: false, 29 | }, 30 | { 31 | type: FieldType.checkbox, 32 | name: 'clearable', 33 | label: 'User can clear selection', 34 | defaultValue: true, 35 | }, 36 | { 37 | type: FieldType.checkbox, 38 | name: 'selectAll', 39 | label: 'User can select all options', 40 | defaultValue: true, 41 | }, 42 | ]; 43 | 44 | export default MultiSelectSettings; 45 | -------------------------------------------------------------------------------- /src/Fields/MultiSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | import * as yup from 'yup'; 5 | 6 | import OrderBoolAscendingVariant from 'mdi-material-ui/OrderBoolAscendingVariant'; 7 | 8 | import Settings from './MultiSelectSettings'; 9 | const Component = lazy( 10 | () => 11 | import( 12 | './MultiSelectComponent' 13 | ) /* webpackChunkName: FormBuilder-MultiSelect */ 14 | ); 15 | 16 | export const MultiSelectConfig: IFieldConfig = { 17 | type: FieldType.multiSelect, 18 | name: 'Multi Select', 19 | group: 'input', 20 | icon: , 21 | dataType: 'string[]', 22 | defaultValue: [], 23 | component: Component as any, 24 | settings: Settings, 25 | validation: (config: Record) => { 26 | const validation: any[][] = [ 27 | ['array'], 28 | ['of', yup.string().trim()], 29 | ['ensure'], 30 | ['compact'], 31 | ]; 32 | 33 | if (config.required === true) 34 | validation.push(['min', 1, 'Please make at least one selection']); 35 | 36 | if (typeof config.max === 'number') 37 | validation.push([ 38 | 'max', 39 | config.max, 40 | `Please make at most ${config.max} selections`, 41 | ]); 42 | 43 | return validation; 44 | }, 45 | }; 46 | export default MultiSelectConfig; 47 | -------------------------------------------------------------------------------- /src/Fields/Paragraph/ParagraphComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { 5 | TextField, 6 | FilledTextFieldProps, 7 | Grid, 8 | FormHelperText, 9 | } from '@mui/material'; 10 | 11 | import FieldAssistiveText from '../../FieldAssistiveText'; 12 | 13 | export interface IParagraphComponentProps 14 | extends IFieldComponentProps, 15 | Omit< 16 | FilledTextFieldProps, 17 | 'variant' | 'name' | 'label' | 'onChange' | 'onBlur' | 'value' | 'ref' 18 | > { 19 | maxCharacters?: number; 20 | } 21 | 22 | export default function ParagraphComponent({ 23 | field: { onChange, onBlur, value, ref }, 24 | fieldState, 25 | formState, 26 | 27 | name, 28 | useFormMethods, 29 | 30 | errorMessage, 31 | assistiveText, 32 | 33 | disabled, 34 | 35 | hiddenLabel = false, 36 | maxCharacters, 37 | ...props 38 | }: IParagraphComponentProps) { 39 | return ( 40 | 50 | 51 | {errorMessage} 52 | 53 | 54 | {assistiveText} 55 | 56 | 57 | 58 | {maxCharacters && ( 59 | 60 | 64 | maxCharacters 65 | } 66 | > 67 | {typeof value === 'string' ? value.length : 0} 68 |  /  69 | {maxCharacters} 70 | 71 | 72 | )} 73 | 74 | ) 75 | } 76 | name={name} 77 | id={`field-${name}`} 78 | multiline 79 | minRows={3} 80 | {...props} 81 | disabled={disabled} 82 | inputProps={{ 83 | required: false, 84 | // https://github.com/react-hook-form/react-hook-form/issues/4485 85 | disabled: false, 86 | readOnly: disabled, 87 | style: disabled ? { cursor: 'default' } : undefined, 88 | maxLength: maxCharacters ?? undefined, 89 | // Required for form-filler 90 | 'data-type': 'textarea', 91 | 'data-label': props.label ?? '', 92 | }} 93 | InputLabelProps={props.placeholder ? { shrink: true } : undefined} 94 | inputRef={ref} 95 | /> 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/Fields/Paragraph/ParagraphSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ParagraphSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.paragraph, 7 | name: 'placeholder', 8 | label: 'Placeholder', 9 | defaultValue: '', 10 | }, 11 | { 12 | type: FieldType.shortText, 13 | name: 'maxCharacters', 14 | label: 'Max characters', 15 | conditional: 'check', 16 | defaultValue: undefined, 17 | format: 'number', 18 | }, 19 | ]; 20 | 21 | export default ParagraphSettings; 22 | -------------------------------------------------------------------------------- /src/Fields/Paragraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import FormTextarea from 'mdi-material-ui/FormTextarea'; 6 | 7 | import Settings from './ParagraphSettings'; 8 | const Component = lazy( 9 | () => 10 | import('./ParagraphComponent') /* webpackChunkName: FormBuilder-Paragraph */ 11 | ); 12 | 13 | export const ParagraphConfig: IFieldConfig = { 14 | type: FieldType.paragraph, 15 | name: 'Paragraph', 16 | group: 'input', 17 | icon: , 18 | dataType: 'string', 19 | defaultValue: '', 20 | component: Component, 21 | settings: Settings, 22 | validation: (config) => { 23 | const validation: any[][] = [['string'], ['trim']]; 24 | 25 | if (typeof config.maxCharacters === 'number') 26 | validation.push([ 27 | 'max', 28 | config.maxCharacters, 29 | 'You have reached the character limit', 30 | ]); 31 | 32 | return validation; 33 | }, 34 | }; 35 | export default ParagraphConfig; 36 | -------------------------------------------------------------------------------- /src/Fields/Radio/RadioComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { 5 | FormControl, 6 | FormControlLabel, 7 | RadioGroup, 8 | RadioGroupProps, 9 | Radio, 10 | Divider, 11 | Box, 12 | } from '@mui/material'; 13 | 14 | import FieldLabel from '../../FieldLabel'; 15 | import FieldErrorMessage from '../../FieldErrorMessage'; 16 | import FieldAssistiveText from '../../FieldAssistiveText'; 17 | 18 | export interface IRadioComponentProps 19 | extends IFieldComponentProps, 20 | Omit { 21 | options: (string | { value: string; label: React.ReactNode })[]; 22 | } 23 | 24 | export default function RadioComponent({ 25 | field: { onChange, onBlur, value, ref }, 26 | fieldState, 27 | formState, 28 | 29 | name, 30 | useFormMethods, 31 | 32 | label, 33 | errorMessage, 34 | assistiveText, 35 | 36 | required, 37 | 38 | options, 39 | ...props 40 | }: IRadioComponentProps) { 41 | return ( 42 | 48 | 54 | {label} 55 | 56 | 57 | 58 | {options.map((item) => { 59 | let option: { label: React.ReactNode; value: string } = { 60 | label: '', 61 | value: '', 62 | }; 63 | if (typeof item === 'object') option = item; 64 | if (typeof item === 'string') option = { label: item, value: item }; 65 | 66 | return ( 67 | 68 | 93 | } 94 | sx={{ 95 | mx: 0, 96 | '& .MuiFormControlLabel-label': { 97 | py: 14 / 8, 98 | ml: 1, 99 | marginTop: '0 !important', 100 | }, 101 | }} 102 | /> 103 | 104 | 105 | ); 106 | })} 107 | 108 | 109 | 110 | {errorMessage} 111 | 112 | {assistiveText} 113 | 114 | 115 | 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/Fields/Radio/RadioSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const RadioSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.list, 7 | name: 'options', 8 | label: 'Options', 9 | defaultValue: [], 10 | }, 11 | ]; 12 | 13 | export default RadioSettings; 14 | -------------------------------------------------------------------------------- /src/Fields/Radio/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import RadioboxMarked from 'mdi-material-ui/RadioboxMarked'; 6 | 7 | import Settings from './RadioSettings'; 8 | const Component = lazy( 9 | () => import('./RadioComponent') /* webpackChunkName: FormBuilder-Radio */ 10 | ); 11 | 12 | export const RadioConfig: IFieldConfig = { 13 | type: FieldType.radio, 14 | name: 'Radio', 15 | group: 'input', 16 | icon: , 17 | dataType: 'string', 18 | defaultValue: '', 19 | component: Component as any, 20 | settings: Settings, 21 | validation: () => [['string'], ['trim']], 22 | }; 23 | export default RadioConfig; 24 | -------------------------------------------------------------------------------- /src/Fields/Score/ScoreComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { FormControl, Box, Typography } from '@mui/material'; 5 | import { ToggleButtonGroup, ToggleButton } from '@mui/material'; 6 | 7 | import FieldLabel from '../../FieldLabel'; 8 | import FieldErrorMessage from '../../FieldErrorMessage'; 9 | import FieldAssistiveText from '../../FieldAssistiveText'; 10 | 11 | export interface IScoreComponentProps extends IFieldComponentProps { 12 | min?: number; 13 | max?: number; 14 | minLabel?: React.ReactNode; 15 | maxLabel?: React.ReactNode; 16 | step?: number; 17 | } 18 | 19 | export default function ScoreComponent({ 20 | field: { onChange, value, ref }, 21 | 22 | label, 23 | errorMessage, 24 | assistiveText, 25 | 26 | disabled, 27 | required, 28 | 29 | min = 0, 30 | max = 10, 31 | minLabel, 32 | maxLabel, 33 | step = 1, 34 | }: IScoreComponentProps) { 35 | const buttons: React.ReactNodeArray = []; 36 | for (let i = min; i <= max; i += step) 37 | buttons.push( 38 | 63 | {i} 64 | 65 | ); 66 | 67 | return ( 68 | 74 | 87 | {label} 88 | 89 | 90 | 101 | {minLabel && ( 102 | 107 | {minLabel} 108 | 109 | )} 110 | 111 | { 114 | if (v !== null) onChange(v); 115 | }} 116 | exclusive 117 | aria-label="Score" 118 | sx={{ 119 | gridRow: 2, 120 | gridColumn: '1 / -1', 121 | 122 | display: 'flex', 123 | justifyContent: 'center', 124 | 125 | m: { xs: -1, md: -0.5 }, 126 | flexWrap: { xs: 'wrap', md: 'nowrap' }, 127 | }} 128 | > 129 | {buttons} 130 | 131 | 132 | {maxLabel && ( 133 | 139 | {maxLabel} 140 | 141 | )} 142 | 143 | 144 | {errorMessage} 145 | 146 | {assistiveText} 147 | 148 | 149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /src/Fields/Score/ScoreSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ScoreSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.shortText, 7 | name: 'min', 8 | label: 'Minimum Value', 9 | defaultValue: 0, 10 | format: 'number', 11 | gridCols: 6, 12 | }, 13 | { 14 | type: FieldType.shortText, 15 | name: 'max', 16 | label: 'Maximum Value', 17 | defaultValue: 10, 18 | format: 'number', 19 | gridCols: 6, 20 | }, 21 | { 22 | type: FieldType.shortText, 23 | name: 'minLabel', 24 | label: 'Minimum Label', 25 | defaultValue: '', 26 | gridCols: 6, 27 | }, 28 | { 29 | type: FieldType.shortText, 30 | name: 'maxLabel', 31 | label: 'Maximum Label', 32 | defaultValue: '', 33 | gridCols: 6, 34 | }, 35 | { 36 | type: FieldType.shortText, 37 | name: 'step', 38 | label: 'Step Size', 39 | defaultValue: 1, 40 | format: 'number', 41 | gridCols: 6, 42 | }, 43 | ]; 44 | 45 | export default ScoreSettings; 46 | -------------------------------------------------------------------------------- /src/Fields/Score/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import Numeric10Box from 'mdi-material-ui/Numeric10Box'; 6 | 7 | import Settings from './ScoreSettings'; 8 | const Component = lazy( 9 | () => import('./ScoreComponent') /* webpackChunkName: FormBuilder-Score */ 10 | ); 11 | 12 | export const ScoreConfig: IFieldConfig = { 13 | type: FieldType.score, 14 | name: 'Score', 15 | group: 'input', 16 | icon: , 17 | dataType: 'number', 18 | defaultValue: undefined, 19 | component: Component, 20 | settings: Settings, 21 | validation: () => [['number']], 22 | }; 23 | export default ScoreConfig; 24 | -------------------------------------------------------------------------------- /src/Fields/ShortText/ShortTextComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { TextField, TextFieldProps, Grid, FormHelperText } from '@mui/material'; 5 | 6 | import FieldAssistiveText from '../../FieldAssistiveText'; 7 | 8 | export interface IShortTextComponentProps 9 | extends IFieldComponentProps, 10 | Omit< 11 | TextFieldProps, 12 | 'variant' | 'name' | 'label' | 'onBlur' | 'onChange' | 'value' | 'ref' 13 | > { 14 | format?: 15 | | 'email' 16 | | 'emailWithName' 17 | | 'phone' 18 | | 'number' 19 | | 'url' 20 | | 'twitter' 21 | | 'linkedin'; 22 | maxCharacters?: number; 23 | } 24 | 25 | export default function ShortTextComponent({ 26 | field: { onChange, onBlur, value, ref }, 27 | fieldState, 28 | formState, 29 | 30 | name, 31 | useFormMethods, 32 | 33 | errorMessage, 34 | assistiveText, 35 | 36 | disabled, 37 | 38 | format, 39 | hiddenLabel = false, 40 | maxCharacters, 41 | ...props 42 | }: IShortTextComponentProps) { 43 | let variantProps: any = {}; 44 | switch (format) { 45 | case 'email': 46 | variantProps = { 47 | type: 'email', 48 | placeholder: 'mail@domain.com', 49 | autoComplete: 'email', 50 | }; 51 | break; 52 | 53 | case 'emailWithName': 54 | variantProps = { 55 | type: 'email', 56 | placeholder: 'Name ', 57 | autoComplete: 'email', 58 | }; 59 | break; 60 | 61 | case 'phone': 62 | variantProps = { 63 | type: 'tel', 64 | placeholder: '+1234567890', 65 | autoComplete: 'tel', 66 | }; 67 | break; 68 | 69 | case 'number': 70 | variantProps = { 71 | placeholder: '1234567890', 72 | inputProps: { 73 | inputMode: 'numeric', 74 | pattern: '\\d*', 75 | }, 76 | }; 77 | break; 78 | 79 | case 'url': 80 | variantProps = { 81 | type: 'url', 82 | placeholder: 'https://example.com', 83 | autoComplete: 'url', 84 | }; 85 | break; 86 | 87 | case 'twitter': 88 | variantProps = { placeholder: '@username' }; 89 | break; 90 | 91 | case 'linkedin': 92 | variantProps = { 93 | type: 'url', 94 | placeholder: 'https://linkedin.com/in/your-name', 95 | autoComplete: 'url', 96 | }; 97 | break; 98 | 99 | default: 100 | break; 101 | } 102 | 103 | const hiddenLabelOverrideProps = hiddenLabel 104 | ? { label: '', 'aria-label': props.label as string, hiddenLabel: true } 105 | : {}; 106 | 107 | const handleChange: TextFieldProps['onChange'] = (e) => 108 | format === 'number' 109 | ? onChange(e.target.value === '' ? '' : Number(e.target.value)) 110 | : onChange(e.target.value); 111 | 112 | return ( 113 | 123 | 124 | {errorMessage} 125 | 126 | 127 | {assistiveText} 128 | 129 | 130 | 131 | {maxCharacters && ( 132 | 133 | 137 | maxCharacters 138 | } 139 | > 140 | {typeof value === 'string' ? value.length : 0} 141 |  /  142 | {maxCharacters} 143 | 144 | 145 | )} 146 | 147 | ) 148 | } 149 | name={name} 150 | id={`field-${name}`} 151 | {...variantProps} 152 | {...props} 153 | {...hiddenLabelOverrideProps} 154 | disabled={disabled} 155 | inputProps={{ 156 | required: false, 157 | // https://github.com/react-hook-form/react-hook-form/issues/4485 158 | disabled: false, 159 | readOnly: disabled, 160 | style: disabled ? { cursor: 'default' } : undefined, 161 | maxLength: maxCharacters ?? undefined, 162 | ...variantProps.inputProps, 163 | // Required for form-filler 164 | 'data-type': 'text', 165 | 'data-label': props.label ?? '', 166 | }} 167 | inputRef={ref} 168 | /> 169 | ); 170 | } 171 | -------------------------------------------------------------------------------- /src/Fields/ShortText/ShortTextSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const ShortTextSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.shortText, 7 | name: 'placeholder', 8 | label: 'Placeholder', 9 | defaultValue: '', 10 | }, 11 | { 12 | type: FieldType.shortText, 13 | name: 'maxCharacters', 14 | label: 'Max characters', 15 | conditional: 'check', 16 | defaultValue: undefined, 17 | format: 'number', 18 | }, 19 | { 20 | type: FieldType.singleSelect, 21 | name: 'format', 22 | label: 'Format', 23 | defaultValue: '', 24 | options: [ 25 | { value: '', label: 'None' }, 26 | { value: 'email', label: 'Email' }, 27 | { value: 'phone', label: 'Phone' }, 28 | { value: 'number', label: 'Number' }, 29 | { value: 'url', label: 'URL' }, 30 | { value: 'twitter', label: 'Twitter handle' }, 31 | { value: 'linkedin', label: 'LinkedIn URL' }, 32 | ], 33 | }, 34 | { 35 | type: FieldType.singleSelect, 36 | name: 'autoComplete', 37 | label: 'Autocomplete Suggestion', 38 | defaultValue: '', 39 | options: [ 40 | { value: '', label: 'None' }, 41 | { value: 'name', label: 'Full Name' }, 42 | { value: 'given-name', label: 'Given Name' }, 43 | { value: 'family-name', label: 'Family Name' }, 44 | { value: 'additional-name', label: 'Middle Name' }, 45 | { value: 'email', label: 'Email' }, 46 | { value: 'organization', label: 'Organisation' }, 47 | { value: 'organization-title', label: 'Organisation title' }, 48 | { value: 'street-address', label: 'Street address' }, 49 | { value: 'country-name', label: 'Country name' }, 50 | { value: 'bday', label: 'Birthday' }, 51 | { value: 'tel', label: 'Phone number' }, 52 | { value: 'url', label: 'URL' }, 53 | ], 54 | assistiveText: 55 | 'Phones will suggest this value when the user clicks on this field. See all available values', 56 | freeText: true, 57 | }, 58 | ]; 59 | 60 | export default ShortTextSettings; 61 | -------------------------------------------------------------------------------- /src/Fields/ShortText/ShortTextValidation.ts: -------------------------------------------------------------------------------- 1 | export const ShortTextValidation = (config: Record) => { 2 | const validation: any[][] = [['string'], ['trim']]; 3 | 4 | switch (config.format) { 5 | case 'email': 6 | validation.push([ 7 | 'email', 8 | 'Please enter the email in the format: mail@domain.com', 9 | ]); 10 | break; 11 | 12 | case 'emailWithName': 13 | validation.push([ 14 | 'matches', 15 | /(?:"?([^"]*)"?\s)?(?:]+)>?)/, // https://stackoverflow.com/a/14011481 16 | { 17 | message: 18 | 'Please enter the email in the format: Name ', 19 | excludeEmptyString: true, 20 | }, 21 | ]); 22 | break; 23 | 24 | case 'phone': 25 | validation.push([ 26 | 'matches', 27 | /^(?=(?:\D*\d\D*){8,14}$)[- \d()+]*/, // https://stackoverflow.com/a/28228199 28 | { 29 | message: 'Please enter a valid phone number', 30 | excludeEmptyString: true, 31 | }, 32 | ]); 33 | break; 34 | 35 | case 'number': 36 | validation[0] = ['number']; 37 | // https://github.com/jquense/yup/issues/298#issuecomment-559017330 38 | validation.push([ 39 | 'transform', 40 | (value: any) => { 41 | if ((typeof value === 'string' && value === '') || isNaN(value)) 42 | return null; 43 | return value; 44 | }, 45 | ]); 46 | validation.push(['nullable']); 47 | break; 48 | 49 | case 'url': 50 | validation.push([ 51 | 'url', 52 | 'Please enter the URL in the format: https://example.com', 53 | ]); 54 | break; 55 | 56 | case 'twitter': 57 | validation.push([ 58 | 'matches', 59 | /^@?(\w){1,15}$/, //https://stackoverflow.com/a/8650024 60 | { 61 | message: 'Please enter the Twitter account in the format: @username', 62 | excludeEmptyString: true, 63 | }, 64 | ]); 65 | break; 66 | 67 | case 'linkedin': 68 | validation.push([ 69 | 'matches', 70 | /^https?:\/\/([a-z]+.)?linkedin\.com\/in\/[a-zA-z\d-]+/, 71 | { 72 | message: 73 | 'Please enter the LinkedIn URL in the format: https://linkedin.com/in/your-name', 74 | excludeEmptyString: true, 75 | }, 76 | ]); 77 | break; 78 | 79 | default: 80 | break; 81 | } 82 | 83 | if (typeof config.maxCharacters === 'number') 84 | validation.push([ 85 | 'max', 86 | config.maxCharacters, 87 | 'You have reached the character limit', 88 | ]); 89 | 90 | return validation; 91 | }; 92 | 93 | export default ShortTextValidation; 94 | -------------------------------------------------------------------------------- /src/Fields/ShortText/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import FormTextbox from 'mdi-material-ui/FormTextbox'; 6 | 7 | import Settings from './ShortTextSettings'; 8 | import validation from './ShortTextValidation'; 9 | const Component = lazy( 10 | () => 11 | import('./ShortTextComponent') /* webpackChunkName: FormBuilder-ShortText */ 12 | ); 13 | 14 | export const ShortTextConfig: IFieldConfig = { 15 | type: FieldType.shortText, 16 | name: 'Short Text', 17 | group: 'input', 18 | icon: , 19 | dataType: 'string', 20 | defaultValue: '', 21 | component: Component, 22 | settings: Settings, 23 | validation, 24 | }; 25 | export default ShortTextConfig; 26 | -------------------------------------------------------------------------------- /src/Fields/SingleSelect/SingleSelectComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | import MultiSelect, { MultiSelectProps } from '@rowy/multiselect'; 4 | 5 | import { TextField, FilledTextFieldProps, MenuItem } from '@mui/material'; 6 | 7 | import FieldAssistiveText from '../../FieldAssistiveText'; 8 | 9 | export interface ISingleSelectComponentProps 10 | extends IFieldComponentProps, 11 | Omit< 12 | FilledTextFieldProps, 13 | 'variant' | 'label' | 'name' | 'onBlur' | 'onChange' | 'ref' | 'value' 14 | >, 15 | Partial< 16 | Omit, 'value' | 'onChange' | 'options' | 'label'> 17 | > { 18 | options: (string | { value: string; label: React.ReactNode })[]; 19 | } 20 | 21 | export default function SingleSelectComponent({ 22 | field: { onChange, onBlur, value, ref }, 23 | fieldState, 24 | formState, 25 | 26 | name, 27 | useFormMethods, 28 | 29 | errorMessage, 30 | assistiveText, 31 | 32 | options = [], 33 | ...props 34 | }: ISingleSelectComponentProps) { 35 | const sanitisedValue = (Array.isArray(value) ? value[0] : value) ?? ''; 36 | 37 | // Render MultiSelect if one of the following props is defined 38 | if ( 39 | [ 40 | props.searchable, 41 | props.labelPlural, 42 | props.freeText, 43 | props.clearable, 44 | ].reduce((a, c) => a || c !== undefined, false) 45 | ) 46 | return ( 47 | onChange(value ?? '')} 53 | onBlur={onBlur} 54 | TextFieldProps={{ 55 | ...props.TextFieldProps, 56 | error: !!errorMessage, 57 | InputLabelProps: { 58 | required: props.required, 59 | ...props.TextFieldProps?.InputLabelProps, 60 | }, 61 | FormHelperTextProps: { 62 | component: 'div', 63 | ...props.TextFieldProps?.FormHelperTextProps, 64 | }, 65 | helperText: (errorMessage || assistiveText) && ( 66 | <> 67 | {errorMessage} 68 | 69 | 73 | {assistiveText} 74 | 75 | 76 | ), 77 | onBlur, 78 | 'data-type': 'multi-select-single', 79 | 'data-label': props.label ?? '', 80 | inputRef: ref, 81 | }} 82 | clearable={props.clearable === true} 83 | /> 84 | ); 85 | 86 | // Render basic Material-UI select 87 | return ( 88 | 96 | {errorMessage} 97 | 98 | 102 | {assistiveText} 103 | 104 | 105 | ) 106 | } 107 | {...props} 108 | onChange={onChange} 109 | onBlur={onBlur} 110 | // Convert string[] value to string 111 | // And remove MUI error when `undefined` or `null` is passed 112 | value={(Array.isArray(value) ? value[0] : value) ?? ''} 113 | data-label={props.label ?? ''} 114 | data-type={'single-select'} 115 | inputProps={{ required: false, ...props.inputProps }} 116 | inputRef={ref} 117 | > 118 | {options.map((option) => { 119 | if (typeof option === 'object') 120 | return ( 121 | 122 | {option.label} 123 | 124 | ); 125 | return ( 126 | 127 | {option} 128 | 129 | ); 130 | })} 131 | 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /src/Fields/SingleSelect/SingleSelectSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const SingleSelectSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.list, 7 | name: 'options', 8 | label: 'Options', 9 | defaultValue: [], 10 | }, 11 | { 12 | type: FieldType.checkbox, 13 | name: 'searchable', 14 | label: 'User can search options', 15 | defaultValue: undefined, 16 | }, 17 | { 18 | type: FieldType.shortText, 19 | name: 'labelPlural', 20 | label: 'Plural Label', 21 | defaultValue: undefined, 22 | displayCondition: 'return values.searchable === true', 23 | }, 24 | { 25 | type: FieldType.checkbox, 26 | name: 'freeText', 27 | label: 'User can add new options', 28 | defaultValue: undefined, 29 | }, 30 | { 31 | type: FieldType.checkbox, 32 | name: 'clearable', 33 | label: 'User can clear selection', 34 | defaultValue: undefined, 35 | }, 36 | ]; 37 | 38 | export default SingleSelectSettings; 39 | -------------------------------------------------------------------------------- /src/Fields/SingleSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import OrderBoolDescending from 'mdi-material-ui/OrderBoolDescending'; 6 | 7 | import Settings from './SingleSelectSettings'; 8 | const Component = lazy( 9 | () => 10 | import( 11 | './SingleSelectComponent' 12 | ) /* webpackChunkName: FormBuilder-SingleSelect */ 13 | ); 14 | 15 | export const SingleSelectConfig: IFieldConfig = { 16 | type: FieldType.singleSelect, 17 | name: 'Single Select', 18 | group: 'input', 19 | icon: , 20 | dataType: 'string', 21 | defaultValue: '', 22 | component: Component as any, 23 | settings: Settings, 24 | validation: () => [['string'], ['trim']], 25 | }; 26 | export default SingleSelectConfig; 27 | -------------------------------------------------------------------------------- /src/Fields/Slider/SliderComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IFieldComponentProps } from '../../types'; 3 | 4 | import { 5 | FormControl, 6 | Stack, 7 | Slider, 8 | SliderProps, 9 | Typography, 10 | } from '@mui/material'; 11 | 12 | import FieldLabel from '../../FieldLabel'; 13 | import FieldErrorMessage from '../../FieldErrorMessage'; 14 | import FieldAssistiveText from '../../FieldAssistiveText'; 15 | 16 | export interface ISliderComponentProps 17 | extends IFieldComponentProps, 18 | Omit { 19 | units?: string; 20 | unitsPlural?: string; 21 | minLabel?: React.ReactNode; 22 | maxLabel?: React.ReactNode; 23 | } 24 | 25 | const valueWithUnits = (value: number, units?: string, unitsPlural?: string) => 26 | `${value} ${(value !== 1 ? unitsPlural || '' : units) || ''}`.trim(); 27 | 28 | export default function SliderComponent({ 29 | field: { onChange, onBlur, value, ref }, 30 | fieldState, 31 | formState, 32 | 33 | name, 34 | useFormMethods, 35 | 36 | label, 37 | errorMessage, 38 | assistiveText, 39 | 40 | required, 41 | 42 | units, 43 | unitsPlural, 44 | minLabel, 45 | maxLabel, 46 | min = 0, 47 | max = 100, 48 | ...props 49 | }: ISliderComponentProps) { 50 | const handleChange = (_: any, value: number | number[]) => { 51 | onChange(value); 52 | onBlur(); 53 | }; 54 | 55 | const getAriaValueText = (value: number) => 56 | valueWithUnits(value, units, unitsPlural); 57 | const getValueLabelFormat = getAriaValueText; 58 | 59 | return ( 60 | 66 | 71 | {label} 72 | 73 | 74 | 75 | 76 | {minLabel || valueWithUnits(min, units, unitsPlural)} 77 | 78 | 79 | 93 | 94 | 95 | {maxLabel || valueWithUnits(max, units, unitsPlural)} 96 | 97 | 98 | 99 | {errorMessage} 100 | 101 | {assistiveText} 102 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/Fields/Slider/SliderSettings.ts: -------------------------------------------------------------------------------- 1 | import { IFieldConfig } from '../../types'; 2 | import { FieldType } from '../../constants/fields'; 3 | 4 | export const SliderSettings: IFieldConfig['settings'] = [ 5 | { 6 | type: FieldType.shortText, 7 | name: 'min', 8 | label: 'Minimum value', 9 | required: true, 10 | defaultValue: 0, 11 | gridCols: 6, 12 | format: 'number', 13 | }, 14 | { 15 | type: FieldType.shortText, 16 | name: 'minLabel', 17 | label: 'Minimum label', 18 | defaultValue: '', 19 | gridCols: 6, 20 | }, 21 | { 22 | type: FieldType.shortText, 23 | name: 'max', 24 | label: 'Maximum value', 25 | required: true, 26 | defaultValue: 0, 27 | gridCols: 6, 28 | format: 'number', 29 | }, 30 | { 31 | type: FieldType.shortText, 32 | name: 'maxLabel', 33 | label: 'Maximum label', 34 | defaultValue: '', 35 | gridCols: 6, 36 | }, 37 | { 38 | type: FieldType.shortText, 39 | name: 'units', 40 | label: 'Units', 41 | defaultValue: '', 42 | gridCols: 6, 43 | placeholder: 'year', 44 | }, 45 | { 46 | type: FieldType.shortText, 47 | name: 'unitsPlural', 48 | label: 'Plural Units', 49 | defaultValue: '', 50 | gridCols: 6, 51 | placeholder: 'years', 52 | }, 53 | { 54 | type: FieldType.shortText, 55 | name: 'step', 56 | label: 'Step Size', 57 | required: true, 58 | defaultValue: 1, 59 | gridCols: 6, 60 | format: 'number', 61 | }, 62 | ]; 63 | 64 | export default SliderSettings; 65 | -------------------------------------------------------------------------------- /src/Fields/Slider/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy } from 'react'; 2 | import { IFieldConfig } from '../../types'; 3 | import { FieldType } from '../../constants/fields'; 4 | 5 | import GestureSwipeHorizontal from 'mdi-material-ui/GestureSwipeHorizontal'; 6 | 7 | import Settings from './SliderSettings'; 8 | const Component = lazy( 9 | () => import('./SliderComponent') /* webpackChunkName: FormBuilder-Slider */ 10 | ); 11 | 12 | export const SliderConfig: IFieldConfig = { 13 | type: FieldType.slider, 14 | name: 'Slider', 15 | group: 'input', 16 | icon: , 17 | dataType: 'number', 18 | defaultValue: 0, 19 | component: Component, 20 | settings: Settings, 21 | validation: (config: Record) => { 22 | const validation: any[][] = [['number']]; 23 | 24 | if (typeof config.min === 'number') 25 | validation.push([ 26 | 'min', 27 | config.min, 28 | 'Please use the slider to set the value', 29 | ]); 30 | 31 | if (typeof config.max === 'number') 32 | validation.push([ 33 | 'max', 34 | config.max, 35 | 'Please use the slider to set the value', 36 | ]); 37 | 38 | return validation; 39 | }, 40 | }; 41 | export default SliderConfig; 42 | -------------------------------------------------------------------------------- /src/Fields/index.ts: -------------------------------------------------------------------------------- 1 | import _find from 'lodash-es/find'; 2 | import _get from 'lodash-es/get'; 3 | import { IFieldConfig } from '../types'; 4 | 5 | import ShortText from './ShortText'; 6 | import Paragraph from './Paragraph'; 7 | import Date from './Date'; 8 | import DateTime from './DateTime'; 9 | import Checkbox from './Checkbox'; 10 | import Radio from './Radio'; 11 | import SingleSelect from './SingleSelect'; 12 | import MultiSelect from './MultiSelect'; 13 | import Slider from './Slider'; 14 | import List from './List'; 15 | import Color from './Color'; 16 | import Score from './Score'; 17 | import Hidden from './Hidden'; 18 | 19 | import ContentHeader from './ContentHeader'; 20 | import ContentSubHeader from './ContentSubHeader'; 21 | import ContentParagraph from './ContentParagraph'; 22 | import ContentImage from './ContentImage'; 23 | 24 | export const FieldConfigs = [ 25 | ShortText, 26 | Paragraph, 27 | Date, 28 | DateTime, 29 | Checkbox, 30 | Radio, 31 | SingleSelect, 32 | MultiSelect, 33 | Slider, 34 | List, 35 | Color, 36 | Score, 37 | Hidden, 38 | 39 | ContentHeader, 40 | ContentSubHeader, 41 | ContentParagraph, 42 | ContentImage, 43 | ]; 44 | 45 | /** Returns specific property of field config */ 46 | export const getFieldProp = (prop: keyof IFieldConfig, fieldType: string) => { 47 | const field = _find(FieldConfigs, { type: fieldType }); 48 | return _get(field, prop); 49 | }; 50 | -------------------------------------------------------------------------------- /src/Form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useForm, UseFormProps, FieldValues } from 'react-hook-form'; 3 | import _isEmpty from 'lodash-es/isEmpty'; 4 | 5 | import useFormSettings from './useFormSettings'; 6 | import FormFields from './FormFields'; 7 | import AutoSave from './AutoSave'; 8 | import SubmitButton, { ISubmitButtonProps } from './SubmitButton'; 9 | import SubmitError, { ISubmitErrorProps } from './SubmitError'; 10 | 11 | import { Fields, CustomComponents } from './types'; 12 | 13 | export interface IFormProps { 14 | fields: Fields; 15 | values?: FieldValues; 16 | onSubmit: ( 17 | values: FieldValues, 18 | event?: React.BaseSyntheticEvent 19 | ) => void; 20 | customComponents?: CustomComponents; 21 | UseFormProps?: UseFormProps; 22 | 23 | autoSave?: boolean; 24 | hideSubmit?: boolean; 25 | SubmitButtonProps?: Partial; 26 | hideSubmitError?: boolean; 27 | SubmitErrorProps?: Partial; 28 | 29 | formHeader?: React.ReactNode; 30 | formFooter?: React.ReactNode; 31 | } 32 | 33 | export default function Form({ 34 | fields, 35 | values, 36 | onSubmit, 37 | customComponents, 38 | UseFormProps = {}, 39 | 40 | autoSave = false, 41 | hideSubmit = autoSave, 42 | SubmitButtonProps = {}, 43 | hideSubmitError = false, 44 | SubmitErrorProps = {}, 45 | 46 | formHeader, 47 | formFooter, 48 | }: IFormProps) { 49 | const { defaultValues, resolver, setOmittedFields } = useFormSettings({ 50 | fields, 51 | values, 52 | customComponents, 53 | }); 54 | 55 | const methods = useForm({ 56 | mode: autoSave ? 'all' : 'onBlur', 57 | defaultValues, 58 | resolver, 59 | shouldUnregister: true, 60 | ...UseFormProps, 61 | }); 62 | const { 63 | handleSubmit, 64 | control, 65 | formState: { errors }, 66 | } = methods; 67 | 68 | const hasErrors = errors 69 | ? (Object.values(errors).reduce( 70 | (a, c) => !!(a || !_isEmpty(c)), 71 | false 72 | ) as boolean) 73 | : false; 74 | 75 | return ( 76 |
77 | {autoSave && ( 78 | 83 | )} 84 | 85 | {formHeader} 86 | 87 | 94 | 95 | {formFooter} 96 | 97 | {!hideSubmit && ( 98 | 99 | )} 100 | 101 | {!hideSubmitError && hasErrors && } 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/FormDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useForm, UseFormProps } from 'react-hook-form'; 3 | import type { FieldValues, Control, UseFormReturn } from 'react-hook-form'; 4 | import _isEmpty from 'lodash-es/isEmpty'; 5 | import _isFunction from 'lodash-es/isFunction'; 6 | 7 | import { 8 | useTheme, 9 | useMediaQuery, 10 | Dialog, 11 | DialogProps as MuiDialogProps, 12 | Stack, 13 | DialogTitle, 14 | IconButton, 15 | DialogContent, 16 | DialogActions, 17 | Button, 18 | ButtonProps, 19 | } from '@mui/material'; 20 | import Portal from '@mui/material/Portal'; 21 | import CloseIcon from '@mui/icons-material/Close'; 22 | 23 | import useFormSettings from './useFormSettings'; 24 | import FormFields from './FormFields'; 25 | import { Fields, CustomComponents } from './types'; 26 | import SubmitError, { ISubmitErrorProps } from './SubmitError'; 27 | import ScrollableDialogContent from './ScrollableDialogContent'; 28 | 29 | export interface IFormDialogProps { 30 | fields: Fields; 31 | values?: FieldValues; 32 | onSubmit: ( 33 | values: FieldValues, 34 | event?: React.BaseSyntheticEvent 35 | ) => void; 36 | customComponents?: CustomComponents; 37 | UseFormProps?: UseFormProps; 38 | 39 | onClose: (reason: 'submit' | 'cancel') => void; 40 | title: React.ReactNode; 41 | formHeader?: React.ReactNode; 42 | formFooter?: React.ReactNode; 43 | 44 | customBody?: (props: { 45 | control: Control; 46 | useFormMethods: UseFormReturn; 47 | setOmittedFields: ReturnType['setOmittedFields']; 48 | }) => React.ReactNode; 49 | customActions?: React.ReactNode; 50 | SubmitButtonProps?: Partial; 51 | CancelButtonProps?: Partial; 52 | hideCancelButton?: boolean; 53 | DialogProps?: Partial; 54 | hideSubmitError?: boolean; 55 | SubmitErrorProps?: Partial; 56 | CloseConfirmProps?: Partial<{ 57 | title: React.ReactNode; 58 | body: React.ReactNode; 59 | confirmButtonProps: Partial; 60 | cancelButtonProps: Partial; 61 | }>; 62 | } 63 | 64 | export default function FormDialog({ 65 | fields, 66 | values, 67 | onSubmit, 68 | customComponents, 69 | UseFormProps = {}, 70 | 71 | onClose, 72 | title, 73 | formHeader, 74 | formFooter, 75 | 76 | customBody, 77 | customActions, 78 | SubmitButtonProps, 79 | CancelButtonProps, 80 | hideCancelButton = false, 81 | DialogProps, 82 | hideSubmitError = false, 83 | SubmitErrorProps = {}, 84 | CloseConfirmProps = {}, 85 | }: IFormDialogProps) { 86 | const theme = useTheme(); 87 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')); 88 | 89 | const { defaultValues, resolver, setOmittedFields } = useFormSettings({ 90 | fields, 91 | values, 92 | customComponents, 93 | }); 94 | 95 | const methods = useForm({ 96 | mode: 'onBlur', 97 | defaultValues, 98 | resolver, 99 | shouldUnregister: true, 100 | ...UseFormProps, 101 | }); 102 | const { 103 | handleSubmit, 104 | control, 105 | formState: { isDirty, errors }, 106 | reset, 107 | } = methods; 108 | 109 | const hasErrors = errors 110 | ? (Object.values(errors).reduce( 111 | (a, c) => !!(a || !_isEmpty(c)), 112 | false 113 | ) as boolean) 114 | : false; 115 | 116 | const [open, setOpen] = useState(true); 117 | const [closeConfirmation, setCloseConfirmation] = useState(false); 118 | const handleClose = (reason: 'submit' | 'cancel') => { 119 | setCloseConfirmation(false); 120 | setOpen(false); 121 | setTimeout(() => { 122 | onClose(reason); 123 | reset(); 124 | }, 300); 125 | }; 126 | const confirmClose = () => { 127 | if (isDirty) setCloseConfirmation(true); 128 | else handleClose('cancel'); 129 | }; 130 | 131 | return ( 132 | 133 |
{ 135 | onSubmit(values, event); 136 | handleClose('submit'); 137 | })} 138 | > 139 | 150 | 151 | 155 | {title} 156 | 157 | 158 | 167 | 168 | 169 | 170 | 171 | 172 | {formHeader} 173 | {_isFunction(customBody) ? ( 174 | customBody({ 175 | control, 176 | useFormMethods: methods, 177 | setOmittedFields, 178 | }) 179 | ) : ( 180 | 187 | )} 188 | {formFooter} 189 | 190 | 191 | 192 | {customActions ?? ( 193 | <> 194 | {!hideCancelButton && ( 195 | 217 | 218 | {}} 221 | disableEscapeKeyDown 222 | aria-labelledby="alert-dialog-title" 223 | aria-describedby="alert-dialog-description" 224 | > 225 | 226 | {CloseConfirmProps.title} 227 | 228 | 229 | 230 | {CloseConfirmProps.body || 'Discard changes?'} 231 | 232 | 233 | 234 | 254 |
255 |
256 | ); 257 | } 258 | -------------------------------------------------------------------------------- /src/FormFields.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Control, UseFormReturn, useWatch } from 'react-hook-form'; 3 | import useFormSettings from './useFormSettings'; 4 | 5 | import { useTheme, Grid, Checkbox } from '@mui/material'; 6 | 7 | import { Fields, CustomComponents } from './types'; 8 | import FieldWrapper, { IFieldWrapperProps } from './FieldWrapper'; 9 | import { getFieldProp } from './fields'; 10 | 11 | export interface IFormFieldsProps { 12 | fields: Fields; 13 | 14 | control: Control; 15 | customComponents?: CustomComponents; 16 | useFormMethods: UseFormReturn; 17 | setOmittedFields: ReturnType['setOmittedFields']; 18 | } 19 | 20 | export default function FormFields({ fields, ...props }: IFormFieldsProps) { 21 | return ( 22 | 23 | {fields.map((field, i) => { 24 | // Call the field displayCondition function with values if necessary 25 | if ( 26 | !!field.displayCondition && 27 | typeof field.displayCondition === 'string' 28 | ) 29 | return ; 30 | 31 | // Otherwise, just use the field object 32 | // If we intentionally hide this field due to form values, don’t render 33 | if (!field) return null; 34 | 35 | // Conditional field 36 | if (field.conditional === 'check') 37 | return ; 38 | 39 | return ( 40 | 41 | ); 42 | })} 43 | 44 | ); 45 | } 46 | 47 | /** 48 | * Wrap the field declaration around this component so we can access 49 | * `useWatch` and it updates whenever the form’s values update 50 | */ 51 | function DependentField({ displayCondition, ...props }: IFieldWrapperProps) { 52 | const values = useWatch({ control: props.control }); 53 | 54 | const [display, setDisplay] = useState(false); 55 | useEffect(() => { 56 | try { 57 | // eslint-disable-next-line no-new-func 58 | const displayConditionFunction = new Function( 59 | 'values', 60 | '"use strict";\n' + displayCondition! 61 | ); 62 | const display = displayConditionFunction(values); 63 | setDisplay(display); 64 | 65 | props.setOmittedFields({ 66 | name: props.name!, 67 | type: display ? 'unOmit' : 'omit', 68 | }); 69 | } catch (e) { 70 | console.error('Failed to evaluate displayCondition function'); 71 | console.error(e); 72 | setDisplay(false); 73 | } 74 | }, [values]); 75 | 76 | useEffect(() => { 77 | if (!display) props.useFormMethods.clearErrors(props.name!); 78 | }, [display]); 79 | 80 | if (!display) return null; 81 | 82 | // Conditional field 83 | if (props.conditional === 'check') return ; 84 | 85 | return ; 86 | } 87 | 88 | /** 89 | * Wrap the field declaration around this component so we can access 90 | * `useWatch` to get the initial conditional checkbox state. 91 | * `getValues` does not seem to work. 92 | */ 93 | function ConditionalField({ conditional, ...props }: IFieldWrapperProps) { 94 | const theme = useTheme(); 95 | 96 | const defaultValue = getFieldProp('defaultValue', props.type); 97 | 98 | const value = useWatch({ control: props.control, name: props.name! }); 99 | const [conditionalState, setConditionalState] = useState( 100 | value !== undefined && value !== null && value !== defaultValue 101 | ); 102 | 103 | useEffect(() => { 104 | props.setOmittedFields({ 105 | name: props.name!, 106 | type: conditionalState ? 'unOmit' : 'omit', 107 | }); 108 | 109 | if (!conditionalState) { 110 | props.useFormMethods.clearErrors(props.name!); 111 | } 112 | }, [conditionalState]); 113 | 114 | return ( 115 | 116 | 117 | 118 | { 121 | setConditionalState(e.target.checked); 122 | }} 123 | inputProps={{ 'aria-label': `Enable field ${props.label}` }} 124 | style={{ margin: theme.spacing(1, 2, 1, -1.5) }} 125 | /> 126 | 127 | 128 | 134 | 135 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /src/FormWithContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | useForm, 4 | UseFormProps, 5 | FieldValues, 6 | FormProvider, 7 | } from 'react-hook-form'; 8 | import _isEmpty from 'lodash-es/isEmpty'; 9 | 10 | import useFormSettings from './useFormSettings'; 11 | import FormFields from './FormFields'; 12 | import AutoSave from './AutoSave'; 13 | import SubmitButton, { ISubmitButtonProps } from './SubmitButton'; 14 | import SubmitError, { ISubmitErrorProps } from './SubmitError'; 15 | 16 | import { Fields, CustomComponents } from './types'; 17 | 18 | export interface IFormWithContextProps { 19 | fields: Fields; 20 | values?: FieldValues; 21 | onSubmit: ( 22 | values: FieldValues, 23 | event?: React.BaseSyntheticEvent 24 | ) => void; 25 | customComponents?: CustomComponents; 26 | UseFormProps?: UseFormProps; 27 | 28 | autoSave?: boolean; 29 | hideSubmit?: boolean; 30 | SubmitButtonProps?: Partial; 31 | hideSubmitError?: boolean; 32 | SubmitErrorProps?: Partial; 33 | 34 | formHeader?: React.ReactNode; 35 | formFooter?: React.ReactNode; 36 | } 37 | 38 | export default function FormWithContext({ 39 | fields, 40 | values, 41 | onSubmit, 42 | customComponents, 43 | UseFormProps = {}, 44 | 45 | autoSave = false, 46 | hideSubmit = autoSave, 47 | SubmitButtonProps = {}, 48 | hideSubmitError = false, 49 | SubmitErrorProps = {}, 50 | 51 | formHeader, 52 | formFooter, 53 | }: IFormWithContextProps) { 54 | const { defaultValues, resolver, setOmittedFields } = useFormSettings({ 55 | fields, 56 | values, 57 | customComponents, 58 | }); 59 | 60 | const methods = useForm({ 61 | mode: autoSave ? 'all' : 'onBlur', 62 | defaultValues, 63 | resolver, 64 | shouldUnregister: true, 65 | ...UseFormProps, 66 | }); 67 | const { 68 | handleSubmit, 69 | control, 70 | formState: { errors }, 71 | } = methods; 72 | 73 | const hasErrors = errors 74 | ? (Object.values(errors).reduce( 75 | (a, c) => !!(a || !_isEmpty(c)), 76 | false 77 | ) as boolean) 78 | : false; 79 | 80 | return ( 81 | 82 |
83 | {autoSave && ( 84 | 89 | )} 90 | 91 | {formHeader} 92 | 93 | 100 | 101 | {formFooter} 102 | 103 | {!hideSubmit && ( 104 | 105 | )} 106 | 107 | {!hideSubmitError && hasErrors && } 108 | 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/ScrollableDialogContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import useScrollInfo from 'react-element-scroll-hook'; 3 | 4 | import { 5 | Divider, 6 | DividerProps, 7 | DialogContent, 8 | DialogContentProps, 9 | } from '@mui/material'; 10 | 11 | const MemoizedDialogContent = memo(function MemoizedDialogContent_({ 12 | setRef, 13 | ...props 14 | }: DialogContentProps & { setRef: any }) { 15 | return ; 16 | }); 17 | 18 | export interface IScrollableDialogContentProps extends DialogContentProps { 19 | disableTopDivider?: boolean; 20 | disableBottomDivider?: boolean; 21 | dividerSx?: DividerProps['sx']; 22 | topDividerSx?: DividerProps['sx']; 23 | bottomDividerSx?: DividerProps['sx']; 24 | } 25 | 26 | export default function ScrollableDialogContent({ 27 | disableTopDivider = false, 28 | disableBottomDivider = false, 29 | dividerSx = [], 30 | topDividerSx = [], 31 | bottomDividerSx = [], 32 | ...props 33 | }: IScrollableDialogContentProps) { 34 | const [scrollInfo, setRef] = useScrollInfo(); 35 | 36 | return ( 37 | <> 38 | {!disableTopDivider && scrollInfo.y.percentage !== null && ( 39 | 0 ? 'visible' : 'hidden', 42 | }} 43 | sx={[ 44 | ...(Array.isArray(dividerSx) ? dividerSx : [dividerSx]), 45 | ...(Array.isArray(topDividerSx) ? topDividerSx : [topDividerSx]), 46 | ]} 47 | /> 48 | )} 49 | 50 | 51 | 52 | {!disableBottomDivider && scrollInfo.y.percentage !== null && ( 53 | 64 | )} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Button, ButtonProps } from '@mui/material'; 4 | 5 | export interface ISubmitButtonProps extends ButtonProps { 6 | label?: React.ReactNode; 7 | } 8 | 9 | export default function SubmitButton({ label, ...props }: ISubmitButtonProps) { 10 | return ( 11 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/SubmitError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Typography, TypographyProps } from '@mui/material'; 4 | 5 | export interface ISubmitErrorProps extends TypographyProps {} 6 | 7 | export default function SubmitError(props: ISubmitErrorProps) { 8 | return ( 9 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/constants/fields.ts: -------------------------------------------------------------------------------- 1 | export enum FieldType { 2 | shortText = 'shortText', 3 | paragraph = 'paragraph', 4 | richText = 'richText', 5 | date = 'date', 6 | dateTime = 'dateTime', 7 | checkbox = 'checkbox', 8 | radio = 'radio', 9 | singleSelect = 'singleSelect', 10 | multiSelect = 'multiSelect', 11 | slider = 'slider', 12 | list = 'list', 13 | color = 'color', 14 | score = 'score', 15 | hidden = 'hidden', 16 | 17 | contentHeader = 'contentHeader', 18 | contentSubHeader = 'contentSubHeader', 19 | contentParagraph = 'contentParagraph', 20 | contentImage = 'contentImage', 21 | } 22 | 23 | export default FieldType; 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AutoSave } from './AutoSave'; 2 | export * from './AutoSave'; 3 | 4 | export { default as FieldAssistiveText } from './FieldAssistiveText'; 5 | export * from './FieldAssistiveText'; 6 | 7 | export { default as FieldErrorMessage } from './FieldErrorMessage'; 8 | export * from './FieldErrorMessage'; 9 | 10 | export { default as FieldLabel } from './FieldLabel'; 11 | export * from './FieldLabel'; 12 | 13 | export { default as FieldSkeleton } from './FieldSkeleton'; 14 | export * from './FieldSkeleton'; 15 | 16 | export { default as FieldWrapper } from './FieldWrapper'; 17 | export * from './FieldWrapper'; 18 | 19 | export { default as Form } from './Form'; 20 | export * from './Form'; 21 | 22 | export { default as FormDialog } from './FormDialog'; 23 | export * from './FormDialog'; 24 | 25 | export { default as FormFields } from './FormFields'; 26 | export * from './FormFields'; 27 | 28 | export { default as FormWithContext } from './FormWithContext'; 29 | export * from './FormWithContext'; 30 | 31 | export { default as ScrollableDialogContent } from './ScrollableDialogContent'; 32 | export * from './ScrollableDialogContent'; 33 | 34 | export { default as SubmitButton } from './SubmitButton'; 35 | export * from './SubmitButton'; 36 | 37 | export { default as SubmitError } from './SubmitError'; 38 | export * from './SubmitError'; 39 | 40 | export { default as fields } from './constants/fields'; 41 | export * from './constants/fields'; 42 | 43 | export { default as CheckboxComponent } from './fields/Checkbox/CheckboxComponent'; 44 | export * from './fields/Checkbox/CheckboxComponent'; 45 | 46 | export * from './fields/Checkbox'; 47 | 48 | export { default as ColorComponent } from './fields/Color/ColorComponent'; 49 | export * from './fields/Color/ColorComponent'; 50 | 51 | export { default as ColorSettings } from './fields/Color/ColorSettings'; 52 | export * from './fields/Color/ColorSettings'; 53 | 54 | export * from './fields/Color'; 55 | 56 | export { default as ContentHeaderComponent } from './fields/ContentHeader/ContentHeaderComponent'; 57 | export * from './fields/ContentHeader/ContentHeaderComponent'; 58 | 59 | export { default as ContentHeaderSettings } from './fields/ContentHeader/ContentHeaderSettings'; 60 | export * from './fields/ContentHeader/ContentHeaderSettings'; 61 | 62 | export * from './fields/ContentHeader'; 63 | 64 | export { default as ContentImageComponent } from './fields/ContentImage/ContentImageComponent'; 65 | export * from './fields/ContentImage/ContentImageComponent'; 66 | 67 | export { default as ContentImageSettings } from './fields/ContentImage/ContentImageSettings'; 68 | export * from './fields/ContentImage/ContentImageSettings'; 69 | 70 | export * from './fields/ContentImage'; 71 | 72 | export { default as ContentParagraphComponent } from './fields/ContentParagraph/ContentParagraphComponent'; 73 | export * from './fields/ContentParagraph/ContentParagraphComponent'; 74 | 75 | export { default as ContentParagraphSettings } from './fields/ContentParagraph/ContentParagraphSettings'; 76 | export * from './fields/ContentParagraph/ContentParagraphSettings'; 77 | 78 | export * from './fields/ContentParagraph'; 79 | 80 | export { default as ContentSubHeaderComponent } from './fields/ContentSubHeader/ContentSubHeaderComponent'; 81 | export * from './fields/ContentSubHeader/ContentSubHeaderComponent'; 82 | 83 | export { default as ContentSubHeaderSettings } from './fields/ContentSubHeader/ContentSubHeaderSettings'; 84 | export * from './fields/ContentSubHeader/ContentSubHeaderSettings'; 85 | 86 | export * from './fields/ContentSubHeader'; 87 | 88 | export { default as DateComponent } from './fields/Date/DateComponent'; 89 | export * from './fields/Date/DateComponent'; 90 | 91 | export * from './fields/Date'; 92 | 93 | export { default as DateTimeComponent } from './fields/DateTime/DateTimeComponent'; 94 | export * from './fields/DateTime/DateTimeComponent'; 95 | 96 | export * from './fields/DateTime'; 97 | 98 | export { default as HiddenComponent } from './fields/Hidden/HiddenComponent'; 99 | export * from './fields/Hidden/HiddenComponent'; 100 | 101 | export { default as HiddenSettings } from './fields/Hidden/HiddenSettings'; 102 | export * from './fields/Hidden/HiddenSettings'; 103 | 104 | export * from './fields/Hidden'; 105 | 106 | export { default as ListComponent } from './fields/List/ListComponent'; 107 | export * from './fields/List/ListComponent'; 108 | 109 | export { default as ListItem } from './fields/List/ListItem'; 110 | export * from './fields/List/ListItem'; 111 | 112 | export { default as ListSettings } from './fields/List/ListSettings'; 113 | export * from './fields/List/ListSettings'; 114 | 115 | export * from './fields/List'; 116 | 117 | export { default as MultiSelectComponent } from './fields/MultiSelect/MultiSelectComponent'; 118 | export * from './fields/MultiSelect/MultiSelectComponent'; 119 | 120 | export { default as MultiSelectSettings } from './fields/MultiSelect/MultiSelectSettings'; 121 | export * from './fields/MultiSelect/MultiSelectSettings'; 122 | 123 | export * from './fields/MultiSelect'; 124 | 125 | export { default as ParagraphComponent } from './fields/Paragraph/ParagraphComponent'; 126 | export * from './fields/Paragraph/ParagraphComponent'; 127 | 128 | export { default as ParagraphSettings } from './fields/Paragraph/ParagraphSettings'; 129 | export * from './fields/Paragraph/ParagraphSettings'; 130 | 131 | export * from './fields/Paragraph'; 132 | 133 | export { default as RadioComponent } from './fields/Radio/RadioComponent'; 134 | export * from './fields/Radio/RadioComponent'; 135 | 136 | export { default as RadioSettings } from './fields/Radio/RadioSettings'; 137 | export * from './fields/Radio/RadioSettings'; 138 | 139 | export * from './fields/Radio'; 140 | 141 | export { default as ScoreComponent } from './fields/Score/ScoreComponent'; 142 | export * from './fields/Score/ScoreComponent'; 143 | 144 | export { default as ScoreSettings } from './fields/Score/ScoreSettings'; 145 | export * from './fields/Score/ScoreSettings'; 146 | 147 | export * from './fields/Score'; 148 | 149 | export { default as ShortTextComponent } from './fields/ShortText/ShortTextComponent'; 150 | export * from './fields/ShortText/ShortTextComponent'; 151 | 152 | export { default as ShortTextSettings } from './fields/ShortText/ShortTextSettings'; 153 | export * from './fields/ShortText/ShortTextSettings'; 154 | 155 | export { default as ShortTextValidation } from './fields/ShortText/ShortTextValidation'; 156 | export * from './fields/ShortText/ShortTextValidation'; 157 | 158 | export * from './fields/ShortText'; 159 | 160 | export { default as SingleSelectComponent } from './fields/SingleSelect/SingleSelectComponent'; 161 | export * from './fields/SingleSelect/SingleSelectComponent'; 162 | 163 | export { default as SingleSelectSettings } from './fields/SingleSelect/SingleSelectSettings'; 164 | export * from './fields/SingleSelect/SingleSelectSettings'; 165 | 166 | export * from './fields/SingleSelect'; 167 | 168 | export { default as SliderComponent } from './fields/Slider/SliderComponent'; 169 | export * from './fields/Slider/SliderComponent'; 170 | 171 | export { default as SliderSettings } from './fields/Slider/SliderSettings'; 172 | export * from './fields/Slider/SliderSettings'; 173 | 174 | export * from './fields/Slider'; 175 | 176 | export * from './fields'; 177 | 178 | export * from './types'; 179 | 180 | export { default as useFormSettings } from './useFormSettings'; 181 | export * from './useFormSettings'; 182 | 183 | export * from './utils'; 184 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { UseFormReturn, UseControllerReturn } from 'react-hook-form'; 2 | import { FieldType } from './constants/fields'; 3 | import { GridProps } from '@mui/material'; 4 | 5 | export type Field = { 6 | type: FieldType | string; 7 | name?: string; 8 | 9 | label?: string; 10 | assistiveText?: string; 11 | 12 | conditional?: 'check' | 'option'; 13 | displayCondition?: string; 14 | required?: boolean; 15 | disabled?: boolean; 16 | validation?: any[][]; 17 | 18 | defaultValue?: any; 19 | gridCols?: 20 | | GridProps['xs'] 21 | | Pick; 22 | disablePadding?: boolean; 23 | disablePaddingTop?: boolean; 24 | 25 | [key: string]: any; 26 | }; 27 | export type Fields = Field[]; 28 | 29 | export interface IFieldComponentProps extends UseControllerReturn { 30 | index: number; 31 | name: string; 32 | useFormMethods: UseFormReturn; 33 | 34 | label: string; 35 | errorMessage?: string; 36 | assistiveText?: string; 37 | 38 | required?: boolean; 39 | disabled?: boolean; 40 | 41 | [key: string]: any; 42 | } 43 | 44 | export type CustomComponent< 45 | P extends IFieldComponentProps = IFieldComponentProps 46 | > = React.ComponentType

| React.LazyExoticComponent>; 47 | 48 | export type CustomComponents< 49 | P extends IFieldComponentProps = IFieldComponentProps 50 | > = { 51 | [type: string]: { 52 | component: CustomComponent

; 53 | defaultValue?: any; 54 | validation?: any[][]; 55 | }; 56 | }; 57 | 58 | export interface IFieldConfig< 59 | P extends IFieldComponentProps = IFieldComponentProps 60 | > { 61 | type: string; 62 | name: string; 63 | group: 'input' | 'content'; 64 | icon: React.ReactNode; 65 | dataType: string; 66 | defaultValue: any; 67 | component: CustomComponent

; 68 | settings: Fields; 69 | validation: (config: Record) => any[][]; 70 | } 71 | -------------------------------------------------------------------------------- /src/useFormSettings.ts: -------------------------------------------------------------------------------- 1 | import { useReducer, useMemo } from 'react'; 2 | import { FieldValues } from 'react-hook-form'; 3 | import { yupResolver } from '@hookform/resolvers/yup'; 4 | 5 | import { Fields, CustomComponents } from './types'; 6 | import { getDefaultValues, getValidationSchema } from './utils'; 7 | 8 | const reducer = ( 9 | state: string[], 10 | action: { name: string; type: 'omit' | 'unOmit' } 11 | ) => { 12 | switch (action.type) { 13 | case 'omit': 14 | if (state.indexOf(action.name) === -1) { 15 | const newState = [...state]; 16 | newState.push(action.name); 17 | return newState; 18 | } 19 | break; 20 | 21 | case 'unOmit': 22 | if (state.indexOf(action.name) > -1) { 23 | const newState = new Set(state); 24 | newState.delete(action.name); 25 | return Array.from(newState); 26 | } 27 | break; 28 | 29 | default: 30 | break; 31 | } 32 | 33 | return state; 34 | }; 35 | 36 | export interface IUseFormSettingsProps { 37 | fields: Fields; 38 | values?: FieldValues; 39 | 40 | customComponents?: CustomComponents; 41 | } 42 | 43 | export function useFormSettings({ 44 | fields, 45 | values, 46 | customComponents, 47 | }: IUseFormSettingsProps) { 48 | const defaultValues = getDefaultValues(fields, customComponents, values); 49 | 50 | const [omittedFields, setOmittedFields] = useReducer(reducer, []); 51 | 52 | const fieldsWithOmissions = useMemo( 53 | () => fields.filter(({ name }) => omittedFields.indexOf(name ?? '') === -1), 54 | [omittedFields, fields] 55 | ); 56 | 57 | const resolver = yupResolver( 58 | getValidationSchema(fieldsWithOmissions, customComponents) 59 | ); 60 | 61 | return { defaultValues, resolver, setOmittedFields }; 62 | } 63 | 64 | export default useFormSettings; 65 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import { ObjectShape } from 'yup/lib/object'; 3 | import _pickBy from 'lodash-es/pickBy'; 4 | import _isEqual from 'lodash-es/isEqual'; 5 | import _set from 'lodash-es/set'; 6 | import _values from 'lodash-es/values'; 7 | import _mapValues from 'lodash-es/mapValues'; 8 | import { getFieldProp } from './fields'; 9 | 10 | import { FieldValues } from 'react-hook-form'; 11 | import { Fields, CustomComponents } from './types'; 12 | 13 | /** 14 | * Creates a single object with all default values of the fields 15 | * @param fields Fields used in the form 16 | * @param customComponents Custom components used in the form 17 | */ 18 | export const getDefaultValues = ( 19 | fields: Fields, 20 | customComponents?: CustomComponents, 21 | mergeValues?: FieldValues 22 | ): FieldValues => { 23 | const defaultValues: FieldValues = {}; 24 | 25 | for (const field of fields) { 26 | if (!field || !field.name || !field.type) continue; 27 | 28 | let defaultValue: any; 29 | 30 | // Get default value if specified in field declaration 31 | if (field.defaultValue !== undefined) { 32 | defaultValue = field.defaultValue; 33 | } 34 | // Get default value from customComponents 35 | else if (!!customComponents && field.type in customComponents) { 36 | defaultValue = customComponents[field.type].defaultValue; 37 | } 38 | // Get default value from built-in components 39 | else { 40 | defaultValue = getFieldProp('defaultValue', field.type); 41 | } 42 | 43 | // If undefined, do not add to defaultValues 44 | // Prevents content fields returning a value 45 | if (defaultValue === undefined) continue; 46 | 47 | // Use lodash set to support nested fields, e.g. `cloudBuild.branch` 48 | _set(defaultValues, field.name, defaultValue); 49 | } 50 | 51 | return { ...defaultValues, ...mergeValues }; 52 | }; 53 | 54 | /** 55 | * Creates a Yup object schema to validate the entire form 56 | * @param fields Fields used in the form 57 | * @param customComponents Custom components used in the form 58 | */ 59 | export const getValidationSchema = ( 60 | fields: Fields, 61 | customComponents?: CustomComponents 62 | ) => { 63 | const objectShape: ObjectShape = {}; 64 | 65 | for (const field of fields) { 66 | if (!field || !field.name) continue; 67 | 68 | let validation: any[][] = []; 69 | 70 | if (!!customComponents && field.type in customComponents) { 71 | // Get default validation from customComponents 72 | validation = customComponents[field.type].validation ?? []; 73 | } else { 74 | // Get default validation from built-in components 75 | const validationFunction = getFieldProp('validation', field.type); 76 | if (validationFunction) validation = validationFunction(field); 77 | } 78 | 79 | // If we intentionally don’t validate this field, e.g. content fields: 80 | if (validation.length === 0) continue; 81 | 82 | // Add the required validation message for all field types 83 | if (field.required === true) 84 | validation.splice(1, 0, [ 85 | 'required', 86 | `${field.label || field.name} is required`, 87 | ]); 88 | 89 | // Append custom validation from the form’s field config to the default validation 90 | // Wrap in lodash values function to support { 0: [], 1: [] } object for Firestore 91 | // Also support nested { 0: { 0: [], 1: … }, […] } with miixed types 92 | const sanitizedValidation = _values(_mapValues(field.validation, _values)); 93 | if (sanitizedValidation.length > 0) 94 | validation = [...validation, ...sanitizedValidation]; 95 | 96 | // Reduce the array of arrays to the Yup schema for this field 97 | const schema = validation.reduce((a, c) => { 98 | const [type, ...args] = c; 99 | // Check the method exists in Yup & call with args 100 | if (type in a) return (a[type as keyof typeof a] as any)(...args); 101 | // Otherwise, return the current schema 102 | return a; 103 | }, yup); 104 | 105 | // Use lodash set to support nested fields, e.g. `cloudBuild.branch` 106 | _set(objectShape, field.name, schema); 107 | } 108 | 109 | // Recursively ensure all nested fields are Yup schemas 110 | // wrapped in Yup object schemas 111 | const recursiveObjectShape = (object: Record) => { 112 | for (const [key, value] of Object.entries(object)) { 113 | if (yup.BaseSchema.prototype.isPrototypeOf(value)) continue; 114 | object[key] = yup.object().shape(recursiveObjectShape(value) as any); 115 | } 116 | return object; 117 | }; 118 | 119 | return yup.object().shape(recursiveObjectShape(objectShape)); 120 | }; 121 | 122 | /** 123 | * Gets the form values that have changed 124 | * @param current Current form values 125 | * @param changed Changed form values (a subset of form values) 126 | * @returns An object with only the form values that changed 127 | */ 128 | export const diffChanges = ( 129 | current: { [key: string]: any }, 130 | changed: { [key: string]: any } 131 | ) => { 132 | return _pickBy(changed, (val, key) => !_isEqual(val, current[key])); 133 | }; 134 | 135 | /** 136 | * Stubs Controller render props 137 | */ 138 | export const controllerRenderPropsStub: any = { 139 | field: { 140 | onChange: () => {}, 141 | onBlur: () => {}, 142 | ref: undefined as any, 143 | }, 144 | fieldState: {}, 145 | formState: {}, 146 | }; 147 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": false, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "@": ["./"], 19 | "*": ["src/*", "node_modules/*"] 20 | }, 21 | "jsx": "react", 22 | "esModuleInterop": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // Not transpiled with TypeScript or Babel, so use plain Es6/Node.js! 4 | module.exports = { 5 | // This function will run for each entry/format/env combination 6 | rollup(config, options) { 7 | const { output, ...restConfig } = config; 8 | const { file, ...restOutput } = output; 9 | // Remove file ref and insert dir to support React.lazy(); 10 | return { 11 | ...restConfig, 12 | output: { 13 | ...restOutput, 14 | dir: path.join(__dirname, 'dist'), 15 | }, 16 | }; // always return a config. 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /types/react-element-scroll-hook.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-element-scroll-hook' { 2 | const hook: any; 3 | export default hook; 4 | } 5 | --------------------------------------------------------------------------------