From 16769dc1da1a4234b837270461e8dde1edbdac9f Mon Sep 17 00:00:00 2001 From: bytasv Date: Fri, 20 Jan 2023 09:24:43 +0200 Subject: [PATCH 01/51] Add form component --- .../toolpad-app/src/runtime/ToolpadApp.tsx | 1 + .../ComponentCatalog/ComponentCatalog.tsx | 1 - .../src/toolpadComponents/index.tsx | 1 + packages/toolpad-components/src/Form.tsx | 83 +++++++++++++++++++ packages/toolpad-components/src/Select.tsx | 6 ++ packages/toolpad-components/src/index.tsx | 2 + 6 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/toolpad-components/src/Form.tsx diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index c95cd7bbecc..7411983f00f 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -327,6 +327,7 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC const props: Record = React.useMemo(() => { return { + name: node.name, ...boundProps, ...onChangeHandlers, ...eventHandlers, diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentCatalog/ComponentCatalog.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentCatalog/ComponentCatalog.tsx index ab65cb9ba3c..e9ae118062f 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentCatalog/ComponentCatalog.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentCatalog/ComponentCatalog.tsx @@ -18,7 +18,6 @@ interface FutureComponentSpec { } const FUTURE_COMPONENTS = new Map([ - ['Form', { url: 'https://github.com/mui/mui-toolpad/issues/749', displayName: 'Form' }], ['Card', { url: 'https://github.com/mui/mui-toolpad/issues/748', displayName: 'Card' }], ['Slider', { url: 'https://github.com/mui/mui-toolpad/issues/746', displayName: 'Slider' }], ['Switch', { url: 'https://github.com/mui/mui-toolpad/issues/745', displayName: 'Switch' }], diff --git a/packages/toolpad-app/src/toolpadComponents/index.tsx b/packages/toolpad-app/src/toolpadComponents/index.tsx index 970815d81eb..8e73acd7a1e 100644 --- a/packages/toolpad-app/src/toolpadComponents/index.tsx +++ b/packages/toolpad-app/src/toolpadComponents/index.tsx @@ -38,6 +38,7 @@ const INTERNAL_COMPONENTS = new Map([ ['Paper', { displayName: 'Paper', builtIn: 'Paper' }], ['Tabs', { displayName: 'Tabs', builtIn: 'Tabs' }], ['Container', { displayName: 'Container', builtIn: 'Container' }], + ['Form', { displayName: 'Form', builtIn: 'Form' }], ]); function createCodeComponent(domNode: appDom.CodeComponentNode): ToolpadComponentDefinition { diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx new file mode 100644 index 00000000000..207b514b017 --- /dev/null +++ b/packages/toolpad-components/src/Form.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Container as MUIContainer, ContainerProps, Button } from '@mui/material'; +import { createComponent } from '@mui/toolpad-core'; +import { SX_PROP_HELPER_TEXT } from './constants'; + +type FormValue = Record; + +interface Props extends ContainerProps { + onSubmit: () => void; + submitLabel: string; + value: FormValue; + onChange: (value: FormValue) => void; +} + +function Form({ children, onSubmit, onChange, submitLabel, sx, ...props }: Props) { + const [formState, setFormState] = React.useState({}); + + const handleSubmit = () => { + debugger; + }; + + const handleChange = (event) => { + const { name, value } = event.target; + debugger; + setFormState({ + ...formState, + [name]: value, + }); + onChange(formState); + }; + + return ( + +
+ + {children} + + + + + +
+ ); +} + +export default createComponent(Form, { + argTypes: { + children: { + visible: false, + typeDef: { type: 'element' }, + control: { type: 'layoutSlot' }, + }, + value: { + helperText: 'The value that is controlled by this text input.', + typeDef: { type: 'object' }, + onChangeProp: 'onChange', + defaultValue: {}, + }, + submitLabel: { + label: 'Submit label', + helperText: 'Submit button label.', + typeDef: { type: 'string' }, + defaultValue: 'Submit', + }, + sx: { + helperText: SX_PROP_HELPER_TEXT, + typeDef: { type: 'object' }, + defaultValue: { padding: 1 }, + }, + }, +}); diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index cc8d407566c..92b6d6cbafd 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -17,7 +17,13 @@ export type SelectProps = Omit & { function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { const handleChange = React.useCallback( (event: React.ChangeEvent) => { + console.log('kickced'); + + debugger; onChange(event.target.value); + + const changeEvent = new Event('change', { bubbles: true }); + event.target.dispatchEvent(changeEvent); }, [onChange], ); diff --git a/packages/toolpad-components/src/index.tsx b/packages/toolpad-components/src/index.tsx index 8cc774c55c8..29ef229885e 100644 --- a/packages/toolpad-components/src/index.tsx +++ b/packages/toolpad-components/src/index.tsx @@ -24,5 +24,7 @@ export { default as Tabs } from './Tabs.js'; export { default as Container } from './Container.js'; +export { default as Form } from './Form.js'; + export { CUSTOM_COLUMN_TYPES, NUMBER_FORMAT_PRESETS, inferColumns, parseColumns } from './DataGrid'; export type { SerializableGridColumn, SerializableGridColumns, NumberFormat } from './DataGrid'; From 37bfae6abb13b8f1c7b62a6f4965b566cd1ab85e Mon Sep 17 00:00:00 2001 From: bytasv Date: Tue, 24 Jan 2023 17:44:03 +0200 Subject: [PATCH 02/51] Add form component --- packages/toolpad-app/package.json | 3 +- .../toolpad-app/src/runtime/ToolpadApp.tsx | 2 +- .../toolpad-components/src/DatePicker.tsx | 13 ++- .../toolpad-components/src/FilePicker.tsx | 5 ++ packages/toolpad-components/src/Form.tsx | 85 +++++++++++-------- packages/toolpad-components/src/Select.tsx | 14 +-- packages/toolpad-components/src/TextField.tsx | 11 ++- yarn.lock | 29 ++++++- 8 files changed, 108 insertions(+), 54 deletions(-) diff --git a/packages/toolpad-app/package.json b/packages/toolpad-app/package.json index 62a163630d4..3550c0b8a98 100644 --- a/packages/toolpad-app/package.json +++ b/packages/toolpad-app/package.json @@ -110,7 +110,8 @@ "superjson": "^1.12.2", "ts-node": "^10.9.1", "web-streams-polyfill": "^3.2.1", - "whatwg-url": "^12.0.0" + "whatwg-url": "^12.0.0", + "formik": "2.2.9" }, "devDependencies": { "@types/babel__code-frame": "^7.0.3", diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index c7a42b4fa19..727c0a92bd1 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -334,7 +334,7 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC ...layoutElementProps, ...reactChildren, }; - }, [boundProps, eventHandlers, layoutElementProps, onChangeHandlers, reactChildren]); + }, [boundProps, eventHandlers, layoutElementProps, onChangeHandlers, reactChildren, node.name]); const previousProps = React.useRef>(props); React.useEffect(() => { diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 349bda1e3f1..f21d3ddf497 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -5,6 +5,7 @@ import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/D import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; +import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; const LOCALE_LOADERS = new Map([ @@ -72,16 +73,19 @@ export interface DatePickerProps size: 'small' | 'medium'; sx: any; defaultValue: string; + name: string; } -function DatePicker({ format, onChange, ...props }: DatePickerProps) { +function DatePicker({ format, onChange, value, ...props }: DatePickerProps) { + const [field, , helpers] = useField(props.name); const handleChange = React.useCallback( - (value: Dayjs | null) => { + (newValue: Dayjs | null) => { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format - const stringValue = value?.format('YYYY-MM-DD') || ''; + const stringValue = newValue?.format('YYYY-MM-DD') || ''; onChange(stringValue); + helpers.setValue(stringValue); }, - [onChange], + [onChange, helpers], ); const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); @@ -92,6 +96,7 @@ function DatePicker({ format, onChange, ...props }: DatePickerProps) { {...props} inputFormat={format || 'L'} onChange={handleChange} + value={value || field.value} renderInput={(params) => ( void; + name: string; }; const readFile = async (file: Blob): Promise => { @@ -32,6 +34,8 @@ const readFile = async (file: Blob): Promise => { }; function FilePicker({ multiple, onChange, ...props }: Props) { + const [, , helpers] = useField(props.name); + const handleChange = async (changeEvent: React.ChangeEvent) => { const filesPromises = Array.from(changeEvent.target.files || []).map(async (file) => { const fullFile: FullFile = { @@ -47,6 +51,7 @@ function FilePicker({ multiple, onChange, ...props }: Props) { const files = await Promise.all(filesPromises); onChange(files); + helpers.setValue(files); }; return ( diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 207b514b017..e62a94ae885 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,56 +1,62 @@ import * as React from 'react'; import { Container as MUIContainer, ContainerProps, Button } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; +import { Formik, useFormikContext } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; type FormValue = Record; interface Props extends ContainerProps { - onSubmit: () => void; + onSubmit: (values: any) => void; submitLabel: string; value: FormValue; onChange: (value: FormValue) => void; } -function Form({ children, onSubmit, onChange, submitLabel, sx, ...props }: Props) { - const [formState, setFormState] = React.useState({}); +// Because: https://github.com/jaredpalmer/formik/issues/271 +function ChangeHandler({ onChange }: any) { + const { values } = useFormikContext(); - const handleSubmit = () => { - debugger; - }; + React.useEffect(() => { + onChange(values); + }, [values, onChange]); - const handleChange = (event) => { - const { name, value } = event.target; - debugger; - setFormState({ - ...formState, - [name]: value, - }); - onChange(formState); - }; + return null; +} +function Form({ children, onSubmit, onChange, submitLabel, sx, defaultValue, ...rest }: Props) { return ( - -
- - {children} + + { + onSubmit(values); + }} + > + {(props) => ( + + + + {children} - - - - + + + + + )} + ); } @@ -68,12 +74,21 @@ export default createComponent(Form, { onChangeProp: 'onChange', defaultValue: {}, }, + defaultValue: { + helperText: 'Set initial valus of form fields.', + typeDef: { type: 'object' }, + defaultValue: {}, + }, submitLabel: { label: 'Submit label', helperText: 'Submit button label.', typeDef: { type: 'string' }, defaultValue: 'Submit', }, + onSubmit: { + helperText: 'Add logic to be executed when the user submit the form.', + typeDef: { type: 'event' }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 92b6d6cbafd..d822a9ad377 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; +import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; export interface SelectOption { @@ -12,20 +13,19 @@ export type SelectProps = Omit & { value: string; onChange: (newValue: string) => void; options: (string | SelectOption)[]; + name: string; }; function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { + const [field] = useField(rest.name); + const handleChange = React.useCallback( (event: React.ChangeEvent) => { - console.log('kickced'); - - debugger; onChange(event.target.value); - const changeEvent = new Event('change', { bubbles: true }); - event.target.dispatchEvent(changeEvent); + field.onChange(event); }, - [onChange], + [onChange, field], ); return ( @@ -33,7 +33,7 @@ function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest select sx={{ ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }} fullWidth={fullWidth} - value={value} + value={value || field.value} onChange={handleChange} {...rest} > diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index d7db9595618..c3738be2f3a 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -5,6 +5,7 @@ import { BoxProps, } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; +import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; export type TextFieldProps = Omit & { @@ -12,17 +13,21 @@ export type TextFieldProps = Omit & { onChange: (newValue: string) => void; alignItems?: BoxProps['alignItems']; justifyContent?: BoxProps['justifyContent']; + name: string; }; -function TextField({ defaultValue, onChange, ...props }: TextFieldProps) { +function TextField({ defaultValue, onChange, value, ...props }: TextFieldProps) { + const [field] = useField(props.name); + const handleChange = React.useCallback( (event: React.ChangeEvent) => { onChange(event.target.value); + field.onChange(event); }, - [onChange], + [onChange, field], ); - return ; + return ; } export default createComponent(TextField, { diff --git a/yarn.lock b/yarn.lock index eea092a5f96..39e52edeee5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5254,6 +5254,11 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" @@ -6516,6 +6521,19 @@ formidable@^2.1.1: once "^1.4.0" qs "^6.11.0" +formik@2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -7084,7 +7102,7 @@ history@^5.2.0, history@^5.3.0: dependencies: "@babel/runtime" "^7.7.6" -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -11095,6 +11113,11 @@ react-error-boundary@^3.1.4: dependencies: "@babel/runtime" "^7.12.5" +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-hook-form@^7.42.1: version "7.42.1" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.42.1.tgz#c6396ca684716d89abc1c7e64343badfd30c56c6" @@ -11564,7 +11587,7 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^4.0.5, rimraf@^4.1.1: +rimraf@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.1.1.tgz#ec29817863e5d82d22bca82f9dc4325be2f1e72b" integrity sha512-Z4Y81w8atcvaJuJuBB88VpADRH66okZAuEm+Jtaufa+s7rZmIz+Hik2G53kGaNytE7lsfXyWktTmfVz0H9xuDg== @@ -12595,7 +12618,7 @@ tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.3: +tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== From 7289e9a608e76eae50c081c2e2559eeefa7a4688 Mon Sep 17 00:00:00 2001 From: bytasv Date: Fri, 27 Jan 2023 13:50:20 +0200 Subject: [PATCH 03/51] Replace with reac-hook-form --- .../toolpad-app/src/runtime/ToolpadApp.tsx | 2 + .../toolpad-components/src/DatePicker.tsx | 10 +-- packages/toolpad-components/src/Form.tsx | 87 ++++++++++--------- packages/toolpad-components/src/Select.tsx | 10 +-- packages/toolpad-components/src/TextField.tsx | 26 ++++-- 5 files changed, 79 insertions(+), 56 deletions(-) diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 727c0a92bd1..f28fd5a0751 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -42,6 +42,7 @@ import { import * as _ from 'lodash-es'; import ErrorIcon from '@mui/icons-material/Error'; import { useBrowserJsRuntime } from '@mui/toolpad-core/jsBrowserRuntime'; +import { FormProvider, useForm } from 'react-hook-form'; import * as appDom from '../appDom'; import { RuntimeState, VersionOrPreview } from '../types'; import { @@ -761,6 +762,7 @@ function parseBindings( } function RenderedPage({ nodeId }: RenderedNodeProps) { + const formMethods = useForm(); const dom = useDomContext(); const page = appDom.getNode(dom, nodeId, 'page'); const { children = [], queries = [] } = appDom.getChildNodes(dom, page); diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index f21d3ddf497..5d7fac79327 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -5,7 +5,7 @@ import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/D import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; -import { useField } from 'formik'; +// import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; const LOCALE_LOADERS = new Map([ @@ -77,15 +77,15 @@ export interface DatePickerProps } function DatePicker({ format, onChange, value, ...props }: DatePickerProps) { - const [field, , helpers] = useField(props.name); + // const [field, , helpers] = useField(props.name); const handleChange = React.useCallback( (newValue: Dayjs | null) => { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format const stringValue = newValue?.format('YYYY-MM-DD') || ''; onChange(stringValue); - helpers.setValue(stringValue); + // helpers.setValue(stringValue); }, - [onChange, helpers], + [onChange], ); const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); @@ -96,7 +96,7 @@ function DatePicker({ format, onChange, value, ...props }: DatePickerProps) { {...props} inputFormat={format || 'L'} onChange={handleChange} - value={value || field.value} + value={value} renderInput={(params) => ( ; @@ -11,52 +11,59 @@ interface Props extends ContainerProps { submitLabel: string; value: FormValue; onChange: (value: FormValue) => void; + defaultValue: Record; } -// Because: https://github.com/jaredpalmer/formik/issues/271 -function ChangeHandler({ onChange }: any) { - const { values } = useFormikContext(); +function Form({ + children, + onSubmit = () => {}, + onChange, + submitLabel, + sx, + value, + defaultValue, + ...rest +}: Props) { + const methods = useForm({ + defaultValues: defaultValue, + resolver: (values) => { + onChange(values); - React.useEffect(() => { - onChange(values); - }, [values, onChange]); + return {}; + }, + mode: 'onChange', + }); - return null; -} + // const values = methods.getValues(); + + // React.useEffect(() => { + // onChange(values); + // console.log('new', values); + // }, [values]); -function Form({ children, onSubmit, onChange, submitLabel, sx, defaultValue, ...rest }: Props) { return ( - { - onSubmit(values); - }} - > - {(props) => ( -
- - - {children} + + + {children} - - - - - )} -
+ + + + +
); } @@ -86,7 +93,7 @@ export default createComponent(Form, { defaultValue: 'Submit', }, onSubmit: { - helperText: 'Add logic to be executed when the user submit the form.', + helperText: 'Add logic to be executed when the user submits the form.', typeDef: { type: 'event' }, }, sx: { diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index d822a9ad377..0695ac818be 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; -import { useField } from 'formik'; +// import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; export interface SelectOption { @@ -17,15 +17,15 @@ export type SelectProps = Omit & { }; function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { - const [field] = useField(rest.name); + // const [field] = useField(rest.name); const handleChange = React.useCallback( (event: React.ChangeEvent) => { onChange(event.target.value); - field.onChange(event); + // field.onChange(event); }, - [onChange, field], + [onChange], ); return ( @@ -33,7 +33,7 @@ function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest select sx={{ ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }} fullWidth={fullWidth} - value={value || field.value} + value={value} onChange={handleChange} {...rest} > diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index c3738be2f3a..a0f56fd8a75 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -5,7 +5,7 @@ import { BoxProps, } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; -import { useField } from 'formik'; +import { useFormContext } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; export type TextFieldProps = Omit & { @@ -16,18 +16,32 @@ export type TextFieldProps = Omit & { name: string; }; -function TextField({ defaultValue, onChange, value, ...props }: TextFieldProps) { - const [field] = useField(props.name); +function TextField({ + defaultValue, + onChange, + value, + onBlur = () => {}, + ref, + ...props +}: TextFieldProps) { + const { register } = useFormContext(); const handleChange = React.useCallback( (event: React.ChangeEvent) => { + console.log('here', event.target.value); onChange(event.target.value); - field.onChange(event); }, - [onChange, field], + [onChange], ); - return ; + const formProps = register(props.name, { + onBlur, + onChange: handleChange, + value, + ref, + }); + + return ; } export default createComponent(TextField, { From 855d8e97420fdd783ba93ad4fb98a87888d79632 Mon Sep 17 00:00:00 2001 From: bytasv Date: Wed, 8 Feb 2023 14:44:11 +0200 Subject: [PATCH 04/51] Use a different form approach --- packages/toolpad-app/src/appDom/index.ts | 18 +++++ .../toolpad-app/src/runtime/ToolpadApp.tsx | 23 ++++++- packages/toolpad-components/src/Button.tsx | 13 +++- .../toolpad-components/src/DatePicker.tsx | 2 - .../toolpad-components/src/FilePicker.tsx | 4 -- packages/toolpad-components/src/Form.tsx | 66 ++++--------------- packages/toolpad-components/src/Select.tsx | 5 -- packages/toolpad-components/src/TextField.tsx | 22 +------ 8 files changed, 65 insertions(+), 88 deletions(-) diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 0b0aee7cfa4..9b52c0d24bc 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -413,6 +413,24 @@ export function getParent(dom: AppDom, child: N): ParentOf return null; } +export function isComponent(node: AppDomNode, component: string): boolean { + return node.type === 'element' && node.attributes.component.value === component; +} + +export function getClosestForm(dom: AppDom, node: AppDomNode): AppDomNode | null { + const parent = getParent(dom, node); + + if (!parent) { + return null; + } + + if (isComponent(parent, 'Form')) { + return parent; + } + + return getClosestForm(dom, parent); +} + type AppDomNodeInitOfType = Omit< AppDomNodeOfType, ReservedNodeProperty | 'name' diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 8a65f23021d..ccc267012ce 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -46,7 +46,6 @@ import * as _ from 'lodash-es'; import ErrorIcon from '@mui/icons-material/Error'; import EditIcon from '@mui/icons-material/Edit'; import { useBrowserJsRuntime } from '@mui/toolpad-core/jsBrowserRuntime'; -import { FormProvider, useForm } from 'react-hook-form'; import * as appDom from '../appDom'; import { RuntimeState, VersionOrPreview } from '../types'; import { @@ -200,6 +199,8 @@ interface RenderedNodeContentProps { function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeContentProps) { const setControlledBinding = useSetControlledBindingContext(); + const dom = useDomContext(); + const nodeId = node.id; const componentConfig = Component[TOOLPAD_COMPONENT]; @@ -281,10 +282,27 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC const value = argType.onChangeHandler ? argType.onChangeHandler(param) : param; setControlledBinding(bindingId, { value }); + + const closestForm = appDom.getClosestForm(dom, node); + + if (closestForm) { + const formBindingId = `${closestForm.id}.props.value`; + + const currentValue = closestForm.props.value?.value || {}; + + const formValue = { + ...currentValue, + [node.name]: value, + }; + + setControlledBinding(formBindingId, { + value: formValue, + }); + } }; return [argType.onChangeProp, handler]; }), - [argTypes, nodeId, setControlledBinding], + [argTypes, nodeId, setControlledBinding, dom, node], ); const navigateToPage = usePageNavigator(); @@ -773,7 +791,6 @@ function parseBindings( } function RenderedPage({ nodeId }: RenderedNodeProps) { - const formMethods = useForm(); const dom = useDomContext(); const page = appDom.getNode(dom, nodeId, 'page'); const { children = [], queries = [] } = appDom.getChildNodes(dom, page); diff --git a/packages/toolpad-components/src/Button.tsx b/packages/toolpad-components/src/Button.tsx index 3dbb0712047..4e96adea8b2 100644 --- a/packages/toolpad-components/src/Button.tsx +++ b/packages/toolpad-components/src/Button.tsx @@ -5,10 +5,15 @@ import { SX_PROP_HELPER_TEXT } from './constants'; interface ButtonProps extends Omit { content: string; + submit: boolean; } -function Button({ content, ...rest }: ButtonProps) { - return {content}; +function Button({ content, submit, ...rest }: ButtonProps) { + return ( + + {content} + + ); } export default createComponent(Button, { @@ -53,6 +58,10 @@ export default createComponent(Button, { helperText: 'Whether the button is disabled.', typeDef: { type: 'boolean' }, }, + submit: { + helperText: 'Whether the button should submit forms.', + typeDef: { type: 'boolean' }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index af1dd27f02a..5f13df5c052 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -5,7 +5,6 @@ import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/D import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; -// import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; const LOCALE_LOADERS = new Map([ @@ -83,7 +82,6 @@ function DatePicker({ format, onChange, value, ...props }: DatePickerProps) { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format const stringValue = newValue?.format('YYYY-MM-DD') || ''; onChange(stringValue); - // helpers.setValue(stringValue); }, [onChange], ); diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 0c0fc2de376..07fefda3946 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; -import { useField } from 'formik'; interface FullFile { name: string; @@ -34,8 +33,6 @@ const readFile = async (file: Blob): Promise => { }; function FilePicker({ multiple, onChange, ...props }: Props) { - const [, , helpers] = useField(props.name); - const handleChange = async (changeEvent: React.ChangeEvent) => { const filesPromises = Array.from(changeEvent.target.files || []).map(async (file) => { const fullFile: FullFile = { @@ -51,7 +48,6 @@ function FilePicker({ multiple, onChange, ...props }: Props) { const files = await Promise.all(filesPromises); onChange(files); - helpers.setValue(files); }; return ( diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index ed95e4bd44c..d6ba1098e4d 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -24,46 +24,15 @@ function Form({ defaultValue, ...rest }: Props) { - const methods = useForm({ - defaultValues: defaultValue, - resolver: (values) => { - onChange(values); + const handleSubmit = (event) => { + event.preventDefault(); - return {}; - }, - mode: 'onChange', - }); - - // const values = methods.getValues(); - - // React.useEffect(() => { - // onChange(values); - // console.log('new', values); - // }, [values]); + onSubmit(value); + }; return ( - -
- {children} - - - - -
-
+
{children}
); } @@ -76,21 +45,9 @@ export default createComponent(Form, { control: { type: 'layoutSlot' }, }, value: { - helperText: 'The value that is controlled by this text input.', - typeDef: { type: 'object' }, + visible: false, + typeDef: { type: 'object', default: {} }, onChangeProp: 'onChange', - defaultValue: {}, - }, - defaultValue: { - helperText: 'Set initial valus of form fields.', - typeDef: { type: 'object' }, - defaultValue: {}, - }, - submitLabel: { - label: 'Submit label', - helperText: 'Submit button label.', - typeDef: { type: 'string' }, - defaultValue: 'Submit', }, onSubmit: { helperText: 'Add logic to be executed when the user submits the form.', @@ -98,8 +55,13 @@ export default createComponent(Form, { }, sx: { helperText: SX_PROP_HELPER_TEXT, - typeDef: { type: 'object' }, - defaultValue: { padding: 1 }, + typeDef: { + type: 'object', + default: { + padding: 1, + border: 'solid 1px #007FFF', + }, + }, }, }, }); diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 87fc4dbe84c..35959439139 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; -// import { useField } from 'formik'; import { SX_PROP_HELPER_TEXT } from './constants'; export interface SelectOption { @@ -17,13 +16,9 @@ export type SelectProps = Omit & { }; function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { - // const [field] = useField(rest.name); - const handleChange = React.useCallback( (event: React.ChangeEvent) => { onChange(event.target.value); - - // field.onChange(event); }, [onChange], ); diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index ef88eca2406..845be85cb07 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -5,7 +5,6 @@ import { BoxProps, } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; -import { useFormContext } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; export type TextFieldProps = Omit & { @@ -16,16 +15,7 @@ export type TextFieldProps = Omit & { name: string; }; -function TextField({ - defaultValue, - onChange, - value, - onBlur = () => {}, - ref, - ...props -}: TextFieldProps) { - const { register } = useFormContext(); - +function TextField({ defaultValue, onChange, value, ref, ...props }: TextFieldProps) { const handleChange = React.useCallback( (event: React.ChangeEvent) => { console.log('here', event.target.value); @@ -33,15 +23,7 @@ function TextField({ }, [onChange], ); - - const formProps = register(props.name, { - onBlur, - onChange: handleChange, - value, - ref, - }); - - return ; + return ; } export default createComponent(TextField, { From 6c7a51fbcbf88e42b62772876b41f2061cad0c39 Mon Sep 17 00:00:00 2001 From: bytasv Date: Wed, 8 Feb 2023 15:10:25 +0200 Subject: [PATCH 05/51] Fix form value setting --- .../toolpad-app/src/runtime/ToolpadApp.tsx | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index ccc267012ce..9daa72bd5ce 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -270,6 +270,30 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC return hookResult; }, [isLayoutNode, liveBindings, nodeId]); + const changeClosestFormValue = React.useCallback( + (value: any) => { + const closestForm = appDom.getClosestForm(dom, node); + + if (closestForm) { + const formBindingId = `${closestForm.id}.props.value`; + + const formBinding = liveBindings[formBindingId]; + + const currentFormValue = formBinding?.value || {}; + + const formValue = { + ...currentFormValue, + [node.name]: value, + }; + + setControlledBinding(formBindingId, { + value: formValue, + }); + } + }, + [setControlledBinding, liveBindings, node, dom], + ); + const onChangeHandlers: Record void> = React.useMemo( () => mapProperties(argTypes, ([key, argType]) => { @@ -283,26 +307,11 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC const value = argType.onChangeHandler ? argType.onChangeHandler(param) : param; setControlledBinding(bindingId, { value }); - const closestForm = appDom.getClosestForm(dom, node); - - if (closestForm) { - const formBindingId = `${closestForm.id}.props.value`; - - const currentValue = closestForm.props.value?.value || {}; - - const formValue = { - ...currentValue, - [node.name]: value, - }; - - setControlledBinding(formBindingId, { - value: formValue, - }); - } + changeClosestFormValue(value); }; return [argType.onChangeProp, handler]; }), - [argTypes, nodeId, setControlledBinding, dom, node], + [argTypes, nodeId, setControlledBinding, changeClosestFormValue], ); const navigateToPage = usePageNavigator(); From 32a72f5f473a2d4837a6b66db6b96e5f736b8cfc Mon Sep 17 00:00:00 2001 From: bytasv Date: Tue, 14 Feb 2023 16:40:05 +0200 Subject: [PATCH 06/51] Use form context and form setter --- .../toolpad-app/src/runtime/ToolpadApp.tsx | 28 +---------- .../src/toolpad/propertyControls/string.tsx | 1 + packages/toolpad-components/src/Form.tsx | 48 +++++++++++-------- packages/toolpad-components/src/Select.tsx | 14 ++++-- packages/toolpad-components/src/TextField.tsx | 19 ++++++-- packages/toolpad-core/src/formContext.tsx | 9 ++++ packages/toolpad-core/src/index.tsx | 2 + 7 files changed, 68 insertions(+), 53 deletions(-) create mode 100644 packages/toolpad-core/src/formContext.tsx diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index e51797a892e..2e63cce0d1a 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -266,30 +266,6 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC return hookResult; }, [isLayoutNode, liveBindings, nodeId]); - const changeClosestFormValue = React.useCallback( - (value: any) => { - const closestForm = appDom.getClosestForm(dom, node); - - if (closestForm) { - const formBindingId = `${closestForm.id}.props.value`; - - const formBinding = liveBindings[formBindingId]; - - const currentFormValue = formBinding?.value || {}; - - const formValue = { - ...currentFormValue, - [node.name]: value, - }; - - setControlledBinding(formBindingId, { - value: formValue, - }); - } - }, - [setControlledBinding, liveBindings, node, dom], - ); - const onChangeHandlers: Record void> = React.useMemo( () => mapProperties(argTypes, ([key, argType]) => { @@ -303,11 +279,11 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC const value = argType.onChangeHandler ? argType.onChangeHandler(param) : param; setControlledBinding(bindingId, { value }); - changeClosestFormValue(value); + console.log(node.name, value); }; return [argType.onChangeProp, handler]; }), - [argTypes, nodeId, setControlledBinding, changeClosestFormValue], + [argTypes, nodeId, setControlledBinding], ); const navigateToPage = usePageNavigator(); diff --git a/packages/toolpad-app/src/toolpad/propertyControls/string.tsx b/packages/toolpad-app/src/toolpad/propertyControls/string.tsx index 0d8d86351cf..cbd0c59568a 100644 --- a/packages/toolpad-app/src/toolpad/propertyControls/string.tsx +++ b/packages/toolpad-app/src/toolpad/propertyControls/string.tsx @@ -5,6 +5,7 @@ import type { EditorProps } from '../../types'; function StringPropEditor({ label, value, onChange, disabled }: EditorProps) { const handleChange = React.useCallback( (event: React.ChangeEvent) => { + debugger; onChange(event.target.value || undefined); }, [onChange], diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index d6ba1098e4d..1da20c3d61a 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,38 +1,41 @@ import * as React from 'react'; import { Container as MUIContainer, ContainerProps, Button } from '@mui/material'; -import { createComponent } from '@mui/toolpad-core'; -import { useForm, FormProvider } from 'react-hook-form'; +import { createComponent, FormValues, FormValuesType, SetFormField } from '@mui/toolpad-core'; import { SX_PROP_HELPER_TEXT } from './constants'; -type FormValue = Record; - interface Props extends ContainerProps { onSubmit: (values: any) => void; submitLabel: string; - value: FormValue; - onChange: (value: FormValue) => void; - defaultValue: Record; + value: FormValuesType; + onChange: (value: FormValuesType) => void; } -function Form({ - children, - onSubmit = () => {}, - onChange, - submitLabel, - sx, - value, - defaultValue, - ...rest -}: Props) { +function Form({ children, onSubmit = () => {}, onChange, submitLabel, sx, value, ...rest }: Props) { const handleSubmit = (event) => { event.preventDefault(); onSubmit(value); }; + const formSetter = React.useCallback( + (formValue: FormValuesType) => { + return (fieldName: string, fieldValue: string) => { + onChange({ + ...formValue, + [fieldName]: fieldValue, + }); + }; + }, + [onChange], + ); + return ( -
{children}
+ + +
{children}
+
+
); } @@ -45,9 +48,14 @@ export default createComponent(Form, { control: { type: 'layoutSlot' }, }, value: { - visible: false, - typeDef: { type: 'object', default: {} }, + typeDef: { type: 'object' }, onChangeProp: 'onChange', + // TODO: why - Type 'string' is not assignable to type 'FormValuesType'. + defaultValueProp: 'defaultValue', + }, + defaultValue: { + helperText: 'A default value for when the inoput is still empty.', + typeDef: { type: 'object', default: {} }, }, onSubmit: { helperText: 'Add logic to be executed when the user submits the form.', diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 35959439139..bacd3529131 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; -import { createComponent } from '@mui/toolpad-core'; +import { createComponent, SetFormField, FormValues } from '@mui/toolpad-core'; import { SX_PROP_HELPER_TEXT } from './constants'; export interface SelectOption { @@ -16,21 +16,29 @@ export type SelectProps = Omit & { }; function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { + const formValues = React.useContext(FormValues); + const setFormField = React.useContext(SetFormField); const handleChange = React.useCallback( (event: React.ChangeEvent) => { + if (setFormField) { + setFormField(rest.name, event.target.value); + return; + } + onChange(event.target.value); }, - [onChange], + [onChange, setFormField], ); const id = React.useId(); + const resolvedValue = (formValues ? formValues[rest.name] : value) || ''; return ( diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 845be85cb07..96c374e8f1a 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -4,7 +4,7 @@ import { TextFieldProps as MuiTextFieldProps, BoxProps, } from '@mui/material'; -import { createComponent } from '@mui/toolpad-core'; +import { createComponent, SetFormField, FormValues } from '@mui/toolpad-core'; import { SX_PROP_HELPER_TEXT } from './constants'; export type TextFieldProps = Omit & { @@ -16,14 +16,25 @@ export type TextFieldProps = Omit & { }; function TextField({ defaultValue, onChange, value, ref, ...props }: TextFieldProps) { + const formValues = React.useContext(FormValues); + const setFormField = React.useContext(SetFormField); const handleChange = React.useCallback( (event: React.ChangeEvent) => { - console.log('here', event.target.value); + if (setFormField) { + setFormField(props.name, event.target.value); + return; + } + onChange(event.target.value); }, - [onChange], + [onChange, setFormField, props.name], + ); + + const resolvedValue = (formValues ? formValues[props.name] : value) || ''; + + return ( + ); - return ; } export default createComponent(TextField, { diff --git a/packages/toolpad-core/src/formContext.tsx b/packages/toolpad-core/src/formContext.tsx new file mode 100644 index 00000000000..14934786216 --- /dev/null +++ b/packages/toolpad-core/src/formContext.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export type FormValuesType = Record; + +export const FormValues = React.createContext(null); + +type FieldSetter = (name: string, value: any) => void; + +export const SetFormField = React.createContext(null); diff --git a/packages/toolpad-core/src/index.tsx b/packages/toolpad-core/src/index.tsx index 553d8962f78..7c947816943 100644 --- a/packages/toolpad-core/src/index.tsx +++ b/packages/toolpad-core/src/index.tsx @@ -22,3 +22,5 @@ export * from './componentsContext.js'; export { default as createQuery } from './createQuery.js'; export * from './createQuery.js'; + +export * from './formContext'; From 1a37903877e071d66541ef11c10e43616b0c5219 Mon Sep 17 00:00:00 2001 From: bytasv Date: Wed, 15 Feb 2023 13:57:32 +0200 Subject: [PATCH 07/51] Pull closest form for prop editor --- packages/toolpad-app/src/runtime/ToolpadApp.tsx | 4 ---- .../toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx | 9 +++++++-- .../toolpad-app/src/toolpad/propertyControls/string.tsx | 1 - 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 2e63cce0d1a..0c05061dc9b 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -195,8 +195,6 @@ interface RenderedNodeContentProps { function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeContentProps) { const setControlledBinding = useSetControlledBindingContext(); - const dom = useDomContext(); - const nodeId = node.id; const componentConfig = Component[TOOLPAD_COMPONENT]; @@ -278,8 +276,6 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC const value = argType.onChangeHandler ? argType.onChangeHandler(param) : param; setControlledBinding(bindingId, { value }); - - console.log(node.name, value); }; return [argType.onChangeProp, handler]; }), diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx index 2a3961f1826..b92cb5e4936 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx @@ -3,7 +3,7 @@ import { ArgTypeDefinition, BindableAttrValue } from '@mui/toolpad-core'; import { Alert, Box } from '@mui/material'; import { useBrowserJsRuntime } from '@mui/toolpad-core/jsBrowserRuntime'; import * as appDom from '../../../appDom'; -import { useDomApi } from '../../DomLoader'; +import { useDomApi, useDom } from '../../DomLoader'; import BindableEditor from './BindableEditor'; import { usePageEditorState } from './PageEditorProvider'; import { getDefaultControl } from '../../propertyControls'; @@ -24,15 +24,20 @@ export default function NodeAttributeEditor

({ argType, props, }: NodeAttributeEditorProps

) { + const { dom } = useDom(); const domApi = useDomApi(); const handlePropChange = React.useCallback( (newValue: BindableAttrValue | null) => { + const closestForm = appDom.getClosestForm(dom, node); + + console.log('what is form', closestForm); + domApi.update((draft) => appDom.setNodeNamespacedProp(draft, node, namespace as any, name, newValue), ); }, - [node, namespace, name, domApi], + [node, namespace, name, domApi, dom], ); const propValue: BindableAttrValue | null = (node as any)[namespace]?.[name] ?? null; diff --git a/packages/toolpad-app/src/toolpad/propertyControls/string.tsx b/packages/toolpad-app/src/toolpad/propertyControls/string.tsx index cbd0c59568a..0d8d86351cf 100644 --- a/packages/toolpad-app/src/toolpad/propertyControls/string.tsx +++ b/packages/toolpad-app/src/toolpad/propertyControls/string.tsx @@ -5,7 +5,6 @@ import type { EditorProps } from '../../types'; function StringPropEditor({ label, value, onChange, disabled }: EditorProps) { const handleChange = React.useCallback( (event: React.ChangeEvent) => { - debugger; onChange(event.target.value || undefined); }, [onChange], From d0dd2d644897762b3aa38d2aaea34c805439f287 Mon Sep 17 00:00:00 2001 From: bytasv Date: Thu, 16 Feb 2023 15:59:09 +0200 Subject: [PATCH 08/51] Fix controlled props editor --- .../PageEditor/NodeAttributeEditor.tsx | 2 +- test/integration/propControls/index.spec.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx index b92cb5e4936..f9a768a0836 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx @@ -51,7 +51,7 @@ export default function NodeAttributeEditor

({ // NOTE: Doesn't make much sense to bind controlled props. In the future we might opt // to make them bindable to other controlled props only - const isDisabled = !!argType.onChangeHandler; + const isDisabled = !!argType.onChangeProp; const isBindable = !isDisabled && namespace !== 'layout'; diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts index 5b8f6b76362..e5ffc10c85c 100644 --- a/test/integration/propControls/index.spec.ts +++ b/test/integration/propControls/index.spec.ts @@ -87,3 +87,34 @@ test('changing defaultValue resets controlled value', async ({ page, api }) => { await expect(firstInput).toHaveValue('New'); await expect(secondInput).toHaveValue('New'); }); + +test('can not change controlled component prop values', async ({ page, api }) => { + const dom = await readJsonFile(path.resolve(__dirname, './domInput.json')); + + const app = await api.mutation.createApp(`App ${generateId()}`, { + from: { kind: 'dom', dom }, + }); + + const editorModel = new ToolpadEditor(page); + + await editorModel.goto(app.id); + + await editorModel.waitForOverlay(); + + const input = editorModel.appCanvas.locator('input').first(); + + await expect(input).toBeVisible(); + + const targetBoundingBox = await input.boundingBox(); + + expect(targetBoundingBox).toBeDefined(); + + await page.mouse.click( + targetBoundingBox!.x + targetBoundingBox!.width / 2, + targetBoundingBox!.y + targetBoundingBox!.height / 2, + ); + + const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); + + await expect(valueControl).toBeDisabled(); +}); From 8605f5c15e5ed3b90d92774e5847dddf922c03c5 Mon Sep 17 00:00:00 2001 From: bytasv Date: Thu, 16 Feb 2023 15:59:21 +0200 Subject: [PATCH 09/51] Hide form value control --- packages/toolpad-components/src/Form.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 1da20c3d61a..3fed4ab48a3 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -48,7 +48,8 @@ export default createComponent(Form, { control: { type: 'layoutSlot' }, }, value: { - typeDef: { type: 'object' }, + visible: false, + typeDef: { type: 'object', default: {} }, onChangeProp: 'onChange', // TODO: why - Type 'string' is not assignable to type 'FormValuesType'. defaultValueProp: 'defaultValue', From a95d9e6ad4327b9827ddbe36a17d11fd30d2a09c Mon Sep 17 00:00:00 2001 From: bytasv Date: Fri, 17 Feb 2023 11:57:14 +0200 Subject: [PATCH 10/51] Fix lint errors --- .../toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx | 9 ++------- packages/toolpad-components/src/Form.tsx | 4 ++-- packages/toolpad-components/src/Select.tsx | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx index f9a768a0836..54855a85c14 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/NodeAttributeEditor.tsx @@ -3,7 +3,7 @@ import { ArgTypeDefinition, BindableAttrValue } from '@mui/toolpad-core'; import { Alert, Box } from '@mui/material'; import { useBrowserJsRuntime } from '@mui/toolpad-core/jsBrowserRuntime'; import * as appDom from '../../../appDom'; -import { useDomApi, useDom } from '../../DomLoader'; +import { useDomApi } from '../../DomLoader'; import BindableEditor from './BindableEditor'; import { usePageEditorState } from './PageEditorProvider'; import { getDefaultControl } from '../../propertyControls'; @@ -24,20 +24,15 @@ export default function NodeAttributeEditor

({ argType, props, }: NodeAttributeEditorProps

) { - const { dom } = useDom(); const domApi = useDomApi(); const handlePropChange = React.useCallback( (newValue: BindableAttrValue | null) => { - const closestForm = appDom.getClosestForm(dom, node); - - console.log('what is form', closestForm); - domApi.update((draft) => appDom.setNodeNamespacedProp(draft, node, namespace as any, name, newValue), ); }, - [node, namespace, name, domApi, dom], + [node, namespace, name, domApi], ); const propValue: BindableAttrValue | null = (node as any)[namespace]?.[name] ?? null; diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 3fed4ab48a3..10dcf8686fa 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Container as MUIContainer, ContainerProps, Button } from '@mui/material'; +import { Container as MUIContainer, ContainerProps } from '@mui/material'; import { createComponent, FormValues, FormValuesType, SetFormField } from '@mui/toolpad-core'; import { SX_PROP_HELPER_TEXT } from './constants'; @@ -11,7 +11,7 @@ interface Props extends ContainerProps { } function Form({ children, onSubmit = () => {}, onChange, submitLabel, sx, value, ...rest }: Props) { - const handleSubmit = (event) => { + const handleSubmit = (event: React.SyntheticEvent) => { event.preventDefault(); onSubmit(value); diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index bacd3529131..91523291913 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -27,7 +27,7 @@ function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest onChange(event.target.value); }, - [onChange, setFormField], + [onChange, setFormField, rest.name], ); const id = React.useId(); From 46f693e38318003f17b99a16dde78c351627a2d1 Mon Sep 17 00:00:00 2001 From: bytasv Date: Fri, 17 Feb 2023 12:49:41 +0200 Subject: [PATCH 11/51] tsignore error --- packages/toolpad-components/src/Form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 10dcf8686fa..06ff57e1d13 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -52,6 +52,7 @@ export default createComponent(Form, { typeDef: { type: 'object', default: {} }, onChangeProp: 'onChange', // TODO: why - Type 'string' is not assignable to type 'FormValuesType'. + // @ts-ignore defaultValueProp: 'defaultValue', }, defaultValue: { From 36497daac866d493382933e575b076845d1656df Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Wed, 1 Mar 2023 11:05:23 +0000 Subject: [PATCH 12/51] Basic functionality with react-hook-form --- packages/toolpad-app/src/appDom/index.ts | 18 ---- .../toolpad-app/src/runtime/ToolpadApp.tsx | 4 +- packages/toolpad-components/package.json | 4 +- packages/toolpad-components/src/Button.tsx | 13 +-- .../toolpad-components/src/DatePicker.tsx | 27 ++++-- .../toolpad-components/src/FilePicker.tsx | 21 +++-- packages/toolpad-components/src/Form.tsx | 82 ++++++++----------- packages/toolpad-components/src/Paper.tsx | 4 +- packages/toolpad-components/src/Select.tsx | 30 ++++--- packages/toolpad-components/src/Tabs.tsx | 4 +- packages/toolpad-components/src/TextField.tsx | 31 ++++--- packages/toolpad-core/src/formContext.tsx | 9 -- packages/toolpad-core/src/index.tsx | 2 - packages/toolpad-core/src/runtime.tsx | 26 ++++-- test/integration/propControls/index.spec.ts | 16 +--- yarn.lock | 35 ++++++++ 16 files changed, 173 insertions(+), 153 deletions(-) delete mode 100644 packages/toolpad-core/src/formContext.tsx diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 9b52c0d24bc..0b0aee7cfa4 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -413,24 +413,6 @@ export function getParent(dom: AppDom, child: N): ParentOf return null; } -export function isComponent(node: AppDomNode, component: string): boolean { - return node.type === 'element' && node.attributes.component.value === component; -} - -export function getClosestForm(dom: AppDom, node: AppDomNode): AppDomNode | null { - const parent = getParent(dom, node); - - if (!parent) { - return null; - } - - if (isComponent(parent, 'Form')) { - return parent; - } - - return getClosestForm(dom, parent); -} - type AppDomNodeInitOfType = Omit< AppDomNodeOfType, ReservedNodeProperty | 'name' diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index f4c67a0ae33..fdba6711a31 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -362,14 +362,13 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC const props: Record = React.useMemo(() => { return { - name: node.name, ...boundProps, ...onChangeHandlers, ...eventHandlers, ...layoutElementProps, ...reactChildren, }; - }, [boundProps, eventHandlers, layoutElementProps, onChangeHandlers, reactChildren, node.name]); + }, [boundProps, eventHandlers, layoutElementProps, onChangeHandlers, reactChildren]); const previousProps = React.useRef>(props); const [hasSetInitialBindings, setHasSetInitialBindings] = React.useState(false); @@ -441,6 +440,7 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC return ( diff --git a/packages/toolpad-components/package.json b/packages/toolpad-components/package.json index 567a1793f32..153475f1178 100644 --- a/packages/toolpad-components/package.json +++ b/packages/toolpad-components/package.json @@ -31,12 +31,14 @@ "url": "https://github.com/mui/mui-toolpad/issues" }, "dependencies": { + "@hookform/resolvers": "^2.9.11", "@mui/material": "^5.11.10", "@mui/toolpad-core": "^0.0.41", "@mui/x-data-grid-pro": "^5.17.25", "@mui/x-date-pickers": "^5.0.20", "dayjs": "^1.11.7", - "markdown-to-jsx": "^7.1.9" + "markdown-to-jsx": "^7.1.9", + "yup": "^1.0.2" }, "devDependencies": { "react": "^18.2.0" diff --git a/packages/toolpad-components/src/Button.tsx b/packages/toolpad-components/src/Button.tsx index 4e96adea8b2..3dbb0712047 100644 --- a/packages/toolpad-components/src/Button.tsx +++ b/packages/toolpad-components/src/Button.tsx @@ -5,15 +5,10 @@ import { SX_PROP_HELPER_TEXT } from './constants'; interface ButtonProps extends Omit { content: string; - submit: boolean; } -function Button({ content, submit, ...rest }: ButtonProps) { - return ( - - {content} - - ); +function Button({ content, ...rest }: ButtonProps) { + return {content}; } export default createComponent(Button, { @@ -58,10 +53,6 @@ export default createComponent(Button, { helperText: 'Whether the button is disabled.', typeDef: { type: 'boolean' }, }, - submit: { - helperText: 'Whether the button should submit forms.', - typeDef: { type: 'boolean' }, - }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 5f13df5c052..ab02797c846 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -3,9 +3,10 @@ import { TextField } from '@mui/material'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/DesktopDatePicker'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import { createComponent } from '@mui/toolpad-core'; +import { createComponent, useNode } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; import { SX_PROP_HELPER_TEXT } from './constants'; +import { FormContext } from './Form'; const LOCALE_LOADERS = new Map([ ['en', () => import('dayjs/locale/en')], @@ -75,8 +76,11 @@ export interface DatePickerProps name: string; } -function DatePicker({ format, onChange, value, ...props }: DatePickerProps) { - // const [field, , helpers] = useField(props.name); +function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { + const nodeRuntime = useNode(); + + const formContext = React.useContext(FormContext); + const handleChange = React.useCallback( (newValue: Dayjs | null) => { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format @@ -88,20 +92,23 @@ function DatePicker({ format, onChange, value, ...props }: DatePickerProps) { const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); + const nodeName = rest.name || nodeRuntime?.nodeName; + return ( ( )} /> @@ -135,6 +142,10 @@ export default createComponent(DatePicker, { helperText: 'A label that describes the content of the date picker. e.g. "Arrival date".', typeDef: { type: 'string' }, }, + name: { + helperText: 'Name of this element. Used as a reference in form data.', + typeDef: { type: 'string' }, + }, variant: { helperText: 'One of the available MUI TextField [variants](https://mui.com/material-ui/react-button/#basic-button). Possible values are `outlined`, `filled` or `standard`', diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 07fefda3946..6b9610b895c 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; -import { createComponent } from '@mui/toolpad-core'; +import { createComponent, useNode } from '@mui/toolpad-core'; +import { FormContext } from './Form'; interface FullFile { name: string; @@ -9,7 +10,7 @@ interface FullFile { base64: null | string; } -export type Props = MuiTextFieldProps & { +export type FilePickerProps = MuiTextFieldProps & { multiple: boolean; onChange: (files: FullFile[]) => void; name: string; @@ -32,7 +33,11 @@ const readFile = async (file: Blob): Promise => { }); }; -function FilePicker({ multiple, onChange, ...props }: Props) { +function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { + const nodeRuntime = useNode(); + + const formContext = React.useContext(FormContext); + const handleChange = async (changeEvent: React.ChangeEvent) => { const filesPromises = Array.from(changeEvent.target.files || []).map(async (file) => { const fullFile: FullFile = { @@ -50,12 +55,14 @@ function FilePicker({ multiple, onChange, ...props }: Props) { onChange(files); }; + const nodeName = rest.name || nodeRuntime?.nodeName; + return ( @@ -74,6 +81,10 @@ export default createComponent(FilePicker, { helperText: 'A label that describes the content of the FilePicker. e.g. "Profile Image".', typeDef: { type: 'string' }, }, + name: { + helperText: 'Name of this element. Used as a reference in form data.', + typeDef: { type: 'string' }, + }, multiple: { helperText: 'Whether the FilePicker should accept multiple files.', typeDef: { type: 'boolean', default: true }, diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 06ff57e1d13..b3a37cb543d 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,63 +1,55 @@ import * as React from 'react'; -import { Container as MUIContainer, ContainerProps } from '@mui/material'; -import { createComponent, FormValues, FormValuesType, SetFormField } from '@mui/toolpad-core'; +import { Container, ContainerProps, Box, Button } from '@mui/material'; +import { createComponent } from '@mui/toolpad-core'; +import { useForm, FieldValues } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as Yup from 'yup'; import { SX_PROP_HELPER_TEXT } from './constants'; -interface Props extends ContainerProps { - onSubmit: (values: any) => void; - submitLabel: string; - value: FormValuesType; - onChange: (value: FormValuesType) => void; -} +export const FormContext = React.createContext | null>(null); -function Form({ children, onSubmit = () => {}, onChange, submitLabel, sx, value, ...rest }: Props) { - const handleSubmit = (event: React.SyntheticEvent) => { - event.preventDefault(); +interface FormProps extends ContainerProps { + validationSchema: Yup.AnyObjectSchema; + onSubmit: (data: FieldValues) => unknown | Promise; +} - onSubmit(value); - }; +function Form({ children, onSubmit, validationSchema, sx, ...rest }: FormProps) { + const form = useForm({ + resolver: yupResolver(validationSchema), + }); - const formSetter = React.useCallback( - (formValue: FormValuesType) => { - return (fieldName: string, fieldValue: string) => { - onChange({ - ...formValue, - [fieldName]: fieldValue, - }); - }; + const handleSubmit = React.useCallback( + (data: Record) => { + onSubmit(data); }, - [onChange], + [onSubmit], ); return ( - - - -

{children}
- - -
+ + +
+ {children} + + + +
+
+
); } export default createComponent(Form, { argTypes: { children: { - visible: false, typeDef: { type: 'element' }, control: { type: 'layoutSlot' }, }, - value: { - visible: false, - typeDef: { type: 'object', default: {} }, - onChangeProp: 'onChange', - // TODO: why - Type 'string' is not assignable to type 'FormValuesType'. - // @ts-ignore - defaultValueProp: 'defaultValue', - }, - defaultValue: { - helperText: 'A default value for when the inoput is still empty.', - typeDef: { type: 'object', default: {} }, + validationSchema: { + helperText: 'Form [Yup](https://www.npmjs.com/package/yup) validation schema.', + typeDef: { type: 'object', default: Yup.object({}) }, }, onSubmit: { helperText: 'Add logic to be executed when the user submits the form.', @@ -65,13 +57,7 @@ export default createComponent(Form, { }, sx: { helperText: SX_PROP_HELPER_TEXT, - typeDef: { - type: 'object', - default: { - padding: 1, - border: 'solid 1px #007FFF', - }, - }, + typeDef: { type: 'object' }, }, }, }); diff --git a/packages/toolpad-components/src/Paper.tsx b/packages/toolpad-components/src/Paper.tsx index 4bd56a000dd..ad5040d394a 100644 --- a/packages/toolpad-components/src/Paper.tsx +++ b/packages/toolpad-components/src/Paper.tsx @@ -3,9 +3,9 @@ import { Paper as MuiPaper, PaperProps as MuiPaperProps } from '@mui/material'; import { createComponent } from '@mui/toolpad-core'; import { SX_PROP_HELPER_TEXT } from './constants'; -function Paper({ children, sx, ...props }: MuiPaperProps) { +function Paper({ children, sx, ...rest }: MuiPaperProps) { return ( - + {children} ); diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 91523291913..f41cb849986 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; -import { createComponent, SetFormField, FormValues } from '@mui/toolpad-core'; +import { createComponent, useNode } from '@mui/toolpad-core'; import { SX_PROP_HELPER_TEXT } from './constants'; +import { FormContext } from './Form'; export interface SelectOption { value: string; @@ -16,31 +17,30 @@ export type SelectProps = Omit & { }; function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { - const formValues = React.useContext(FormValues); - const setFormField = React.useContext(SetFormField); + const nodeRuntime = useNode(); + + const formContext = React.useContext(FormContext); + const handleChange = React.useCallback( (event: React.ChangeEvent) => { - if (setFormField) { - setFormField(rest.name, event.target.value); - return; - } - onChange(event.target.value); }, - [onChange, setFormField, rest.name], + [onChange], ); const id = React.useId(); - const resolvedValue = (formValues ? formValues[rest.name] : value) || ''; + + const nodeName = rest.name || nodeRuntime?.nodeName; return ( {options.map((option, i) => { const parsedOption: SelectOption = @@ -80,6 +80,10 @@ export default createComponent(Select, { helperText: 'A label that describes the option that can be selected. e.g. "Country".', typeDef: { type: 'string', default: '' }, }, + name: { + helperText: 'Name of this element. Used as a reference in form data.', + typeDef: { type: 'string' }, + }, variant: { helperText: 'One of the available MUI TextField [variants](https://mui.com/material-ui/react-button/#basic-button). Possible values are `outlined`, `filled` or `standard`', diff --git a/packages/toolpad-components/src/Tabs.tsx b/packages/toolpad-components/src/Tabs.tsx index e51f33d56db..dbf27c9996d 100644 --- a/packages/toolpad-components/src/Tabs.tsx +++ b/packages/toolpad-components/src/Tabs.tsx @@ -7,14 +7,14 @@ interface TabProps { name: string; } -interface Props { +interface TabsProps { value: string; onChange: (value: number) => void; tabs: TabProps[]; defaultValue: string; } -function Tabs({ value, onChange, tabs, defaultValue }: Props) { +function Tabs({ value, onChange, tabs, defaultValue }: TabsProps) { return ( & { value: string; @@ -15,25 +16,27 @@ export type TextFieldProps = Omit & { name: string; }; -function TextField({ defaultValue, onChange, value, ref, ...props }: TextFieldProps) { - const formValues = React.useContext(FormValues); - const setFormField = React.useContext(SetFormField); +function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldProps) { + const nodeRuntime = useNode(); + + const formContext = React.useContext(FormContext); + const handleChange = React.useCallback( (event: React.ChangeEvent) => { - if (setFormField) { - setFormField(props.name, event.target.value); - return; - } - onChange(event.target.value); }, - [onChange, setFormField, props.name], + [onChange], ); - const resolvedValue = (formValues ? formValues[props.name] : value) || ''; + const nodeName = rest.name || nodeRuntime?.nodeName; return ( - + ); } @@ -55,6 +58,10 @@ export default createComponent(TextField, { helperText: 'A label that describes the content of the text field. e.g. "First name".', typeDef: { type: 'string' }, }, + name: { + helperText: 'Name of this element. Used as a reference in form data.', + typeDef: { type: 'string' }, + }, variant: { helperText: 'One of the available MUI TextField [variants](https://mui.com/material-ui/react-button/#basic-button). Possible values are `outlined`, `filled` or `standard`', diff --git a/packages/toolpad-core/src/formContext.tsx b/packages/toolpad-core/src/formContext.tsx deleted file mode 100644 index 14934786216..00000000000 --- a/packages/toolpad-core/src/formContext.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -export type FormValuesType = Record; - -export const FormValues = React.createContext(null); - -type FieldSetter = (name: string, value: any) => void; - -export const SetFormField = React.createContext(null); diff --git a/packages/toolpad-core/src/index.tsx b/packages/toolpad-core/src/index.tsx index 7c947816943..553d8962f78 100644 --- a/packages/toolpad-core/src/index.tsx +++ b/packages/toolpad-core/src/index.tsx @@ -22,5 +22,3 @@ export * from './componentsContext.js'; export { default as createQuery } from './createQuery.js'; export * from './createQuery.js'; - -export * from './formContext'; diff --git a/packages/toolpad-core/src/runtime.tsx b/packages/toolpad-core/src/runtime.tsx index c65e29d6603..6963251e102 100644 --- a/packages/toolpad-core/src/runtime.tsx +++ b/packages/toolpad-core/src/runtime.tsx @@ -19,7 +19,13 @@ declare global { } } -export const NodeRuntimeContext = React.createContext(null); +export const NodeRuntimeContext = React.createContext<{ + nodeId: string | null; + nodeName: string | null; +}>({ + nodeId: null, + nodeName: null, +}); export const CanvasEventsContext = React.createContext>(mitt()); // NOTE: These props aren't used, they are only there to transfer information from the @@ -82,12 +88,14 @@ function NodeFiberHost({ children }: NodeFiberHostProps) { export interface NodeRuntimeWrapperProps { children: React.ReactElement; nodeId: string; + nodeName: string; componentConfig: ComponentConfig; NodeError: React.ComponentType; } export function NodeRuntimeWrapper({ nodeId, + nodeName, componentConfig, children, NodeError, @@ -109,9 +117,11 @@ export function NodeRuntimeWrapper({ [NodeError, componentConfig, nodeId], ); + const nodeRuntimeValue = React.useMemo(() => ({ nodeId, nodeName }), [nodeId, nodeName]); + return ( - + { + nodeId: string | null; + nodeName: string | null; updateAppDomConstProp: ( key: K, value: React.SetStateAction, @@ -133,7 +145,7 @@ export interface NodeRuntime

{ } export function useNode

(): NodeRuntime

| null { - const nodeId = React.useContext(NodeRuntimeContext); + const { nodeId, nodeName } = React.useContext(NodeRuntimeContext); const canvasEvents = React.useContext(CanvasEventsContext); return React.useMemo(() => { @@ -141,6 +153,8 @@ export function useNode

(): NodeRuntime

| null { return null; } return { + nodeId, + nodeName, updateAppDomConstProp: (prop, value) => { canvasEvents.emit('propUpdated', { nodeId, @@ -149,7 +163,7 @@ export function useNode

(): NodeRuntime

| null { }); }, }; - }, [canvasEvents, nodeId]); + }, [canvasEvents, nodeId, nodeName]); } export interface PlaceholderProps { @@ -159,7 +173,7 @@ export interface PlaceholderProps { } export function Placeholder({ prop, children, hasLayout = false }: PlaceholderProps) { - const nodeId = React.useContext(NodeRuntimeContext); + const { nodeId } = React.useContext(NodeRuntimeContext); if (!nodeId) { return {children}; } @@ -183,7 +197,7 @@ export interface SlotsProps { } export function Slots({ prop, children }: SlotsProps) { - const nodeId = React.useContext(NodeRuntimeContext); + const { nodeId } = React.useContext(NodeRuntimeContext); if (!nodeId) { return {children}; } diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts index e5ffc10c85c..1a637e7c190 100644 --- a/test/integration/propControls/index.spec.ts +++ b/test/integration/propControls/index.spec.ts @@ -88,7 +88,7 @@ test('changing defaultValue resets controlled value', async ({ page, api }) => { await expect(secondInput).toHaveValue('New'); }); -test('can not change controlled component prop values', async ({ page, api }) => { +test('cannot change controlled component prop values', async ({ page, api }) => { const dom = await readJsonFile(path.resolve(__dirname, './domInput.json')); const app = await api.mutation.createApp(`App ${generateId()}`, { @@ -98,23 +98,11 @@ test('can not change controlled component prop values', async ({ page, api }) => const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); - await editorModel.waitForOverlay(); const input = editorModel.appCanvas.locator('input').first(); - - await expect(input).toBeVisible(); - - const targetBoundingBox = await input.boundingBox(); - - expect(targetBoundingBox).toBeDefined(); - - await page.mouse.click( - targetBoundingBox!.x + targetBoundingBox!.width / 2, - targetBoundingBox!.y + targetBoundingBox!.height / 2, - ); + await clickCenter(page, input); const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); - await expect(valueControl).toBeDisabled(); }); diff --git a/yarn.lock b/yarn.lock index 353bddf348e..1cf6e7822a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -925,6 +925,11 @@ dependencies: googleapis-common "^6.0.3" +"@hookform/resolvers@^2.9.11": + version "2.9.11" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" + integrity sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ== + "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -10188,6 +10193,11 @@ prop-types@^15.0.0, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.2, object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -11673,6 +11683,11 @@ through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tiny-glob@^0.2.9: version "0.2.9" resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" @@ -11722,6 +11737,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== + totalist@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" @@ -11888,6 +11908,11 @@ type-fest@^1.0.1, type-fest@^1.2.1, type-fest@^1.2.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + type-fest@^3.0.0: version "3.5.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.5.2.tgz#16ff97c5dc1fd6bd6d50ef3c6ba92cc9c1add859" @@ -12544,6 +12569,16 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +yup@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.0.2.tgz#1cf485f407f77e0407b450311f2981a1e66f7c58" + integrity sha512-Lpi8nITFKjWtCoK3yQP8MUk78LJmHWqbFd0OOMXTar+yjejlQ4OIIoZgnTW1bnEUKDw6dZBcy3/IdXnt2KDUow== + dependencies: + property-expr "^2.0.5" + tiny-case "^1.0.3" + toposort "^2.0.2" + type-fest "^2.19.0" + zip-stream@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79" From 86271e7f958bf3b80a2942b45251763fc40d7741 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Wed, 1 Mar 2023 19:13:21 +0000 Subject: [PATCH 13/51] Store form values in scope --- packages/toolpad-components/src/Form.tsx | 33 +++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index b3a37cb543d..a29843331cc 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -9,21 +9,27 @@ import { SX_PROP_HELPER_TEXT } from './constants'; export const FormContext = React.createContext | null>(null); interface FormProps extends ContainerProps { + value: FieldValues; + onChange: (newValue: FieldValues) => void; validationSchema: Yup.AnyObjectSchema; - onSubmit: (data: FieldValues) => unknown | Promise; + onSubmit: (data?: FieldValues) => unknown | Promise; } -function Form({ children, onSubmit, validationSchema, sx, ...rest }: FormProps) { +function Form({ children, onChange, onSubmit, validationSchema, sx, ...rest }: FormProps) { const form = useForm({ resolver: yupResolver(validationSchema), }); - const handleSubmit = React.useCallback( - (data: Record) => { - onSubmit(data); - }, - [onSubmit], - ); + const handleSubmit = React.useCallback(() => { + onSubmit(); + }, [onSubmit]); + + React.useEffect(() => { + const formSubscription = form.watch((value) => { + onChange(value); + }); + return () => formSubscription.unsubscribe(); + }, [form, onChange]); return ( @@ -47,13 +53,20 @@ export default createComponent(Form, { typeDef: { type: 'element' }, control: { type: 'layoutSlot' }, }, + value: { + helperText: 'The value that is controlled by this text input.', + typeDef: { type: 'object', default: {} }, + onChangeProp: 'onChange', + }, validationSchema: { helperText: 'Form [Yup](https://www.npmjs.com/package/yup) validation schema.', - typeDef: { type: 'object', default: Yup.object({}) }, + typeDef: { type: 'object', default: Yup.object() }, }, onSubmit: { helperText: 'Add logic to be executed when the user submits the form.', - typeDef: { type: 'event' }, + typeDef: { + type: 'event', + }, }, sx: { helperText: SX_PROP_HELPER_TEXT, From 7f37b806e1f10f158c8466d563bb6cef7cdb8e99 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Wed, 1 Mar 2023 19:52:59 +0000 Subject: [PATCH 14/51] Add functioning validation --- packages/toolpad-components/package.json | 4 +- .../toolpad-components/src/DatePicker.tsx | 4 +- .../toolpad-components/src/FilePicker.tsx | 7 ++- packages/toolpad-components/src/Form.tsx | 58 ++++++++++++------- packages/toolpad-components/src/Select.tsx | 8 +-- packages/toolpad-components/src/TextField.tsx | 6 +- yarn.lock | 35 ----------- 7 files changed, 52 insertions(+), 70 deletions(-) diff --git a/packages/toolpad-components/package.json b/packages/toolpad-components/package.json index 153475f1178..567a1793f32 100644 --- a/packages/toolpad-components/package.json +++ b/packages/toolpad-components/package.json @@ -31,14 +31,12 @@ "url": "https://github.com/mui/mui-toolpad/issues" }, "dependencies": { - "@hookform/resolvers": "^2.9.11", "@mui/material": "^5.11.10", "@mui/toolpad-core": "^0.0.41", "@mui/x-data-grid-pro": "^5.17.25", "@mui/x-date-pickers": "^5.0.20", "dayjs": "^1.11.7", - "markdown-to-jsx": "^7.1.9", - "yup": "^1.0.2" + "markdown-to-jsx": "^7.1.9" }, "devDependencies": { "react": "^18.2.0" diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index ab02797c846..00371eab7da 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -79,7 +79,7 @@ export interface DatePickerProps function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { const nodeRuntime = useNode(); - const formContext = React.useContext(FormContext); + const { form, validationRules } = React.useContext(FormContext); const handleChange = React.useCallback( (newValue: Dayjs | null) => { @@ -108,7 +108,7 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { variant={rest.variant} size={rest.size} sx={rest.sx} - {...(formContext && nodeName && formContext.register(nodeName))} + {...(form && nodeName && form.register(nodeName, validationRules[nodeName]))} /> )} /> diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 6b9610b895c..34376fd9355 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -36,7 +36,7 @@ const readFile = async (file: Blob): Promise => { function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { const nodeRuntime = useNode(); - const formContext = React.useContext(FormContext); + const { form, validationRules } = React.useContext(FormContext); const handleChange = async (changeEvent: React.ChangeEvent) => { const filesPromises = Array.from(changeEvent.target.files || []).map(async (file) => { @@ -62,7 +62,10 @@ function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { {...rest} type="file" value={undefined} - inputProps={{ multiple, ...(formContext && nodeName && formContext.register(nodeName)) }} + inputProps={{ + multiple, + ...(form && nodeName && form.register(nodeName, validationRules[nodeName])), + }} onChange={handleChange} InputLabelProps={{ shrink: true }} /> diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index a29843331cc..2480944a87b 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,29 +1,42 @@ import * as React from 'react'; -import { Container, ContainerProps, Box, Button } from '@mui/material'; +import { Container, ContainerProps, Box } from '@mui/material'; +import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; -import { useForm, FieldValues } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; +import { useForm, FieldValues, RegisterOptions } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; -export const FormContext = React.createContext | null>(null); +type FormValidationRules = Record< + string, + Pick, 'required' | 'min' | 'max' | 'maxLength' | 'minLength'> +>; + +export const FormContext = React.createContext<{ + form: ReturnType | null; + validationRules: FormValidationRules; +}>({ + form: null, + validationRules: {}, +}); interface FormProps extends ContainerProps { value: FieldValues; onChange: (newValue: FieldValues) => void; - validationSchema: Yup.AnyObjectSchema; + validationRules: FormValidationRules; onSubmit: (data?: FieldValues) => unknown | Promise; } -function Form({ children, onChange, onSubmit, validationSchema, sx, ...rest }: FormProps) { - const form = useForm({ - resolver: yupResolver(validationSchema), - }); +function Form({ children, onChange, onSubmit, validationRules, sx, ...rest }: FormProps) { + const form = useForm(); - const handleSubmit = React.useCallback(() => { - onSubmit(); + const handleSubmit = React.useCallback(async () => { + await onSubmit(); }, [onSubmit]); + // Set initial form values + React.useEffect(() => { + onChange(form.getValues()); + }, [form, onChange]); + React.useEffect(() => { const formSubscription = form.watch((value) => { onChange(value); @@ -31,15 +44,20 @@ function Form({ children, onChange, onSubmit, validationSchema, sx, ...rest }: F return () => formSubscription.unsubscribe(); }, [form, onChange]); + const formContextValue = React.useMemo( + () => ({ form, validationRules }), + [form, validationRules], + ); + return ( - +

{children} - +
@@ -58,15 +76,13 @@ export default createComponent(Form, { typeDef: { type: 'object', default: {} }, onChangeProp: 'onChange', }, - validationSchema: { - helperText: 'Form [Yup](https://www.npmjs.com/package/yup) validation schema.', - typeDef: { type: 'object', default: Yup.object() }, + validationRules: { + helperText: 'Validation rules for form fields.', + typeDef: { type: 'object', default: {} }, }, onSubmit: { helperText: 'Add logic to be executed when the user submits the form.', - typeDef: { - type: 'event', - }, + typeDef: { type: 'event' }, }, sx: { helperText: SX_PROP_HELPER_TEXT, diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index f41cb849986..dfedeca0fef 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -16,10 +16,10 @@ export type SelectProps = Omit & { name: string; }; -function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { +function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProps) { const nodeRuntime = useNode(); - const formContext = React.useContext(FormContext); + const { form, validationRules } = React.useContext(FormContext); const handleChange = React.useCallback( (event: React.ChangeEvent) => { @@ -38,8 +38,8 @@ function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest select sx={{ ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }} fullWidth={fullWidth} - {...(formContext && nodeName - ? formContext.register(nodeName) + {...(form && nodeName + ? { ...form.register(nodeName, validationRules[nodeName]) } : { value, onChange: handleChange })} > {options.map((option, i) => { diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 4dca806579e..6bff1e712c1 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -19,7 +19,7 @@ export type TextFieldProps = Omit & { function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldProps) { const nodeRuntime = useNode(); - const formContext = React.useContext(FormContext); + const { form, validationRules } = React.useContext(FormContext); const handleChange = React.useCallback( (event: React.ChangeEvent) => { @@ -33,8 +33,8 @@ function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldPro return ( ); diff --git a/yarn.lock b/yarn.lock index 1cf6e7822a2..353bddf348e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -925,11 +925,6 @@ dependencies: googleapis-common "^6.0.3" -"@hookform/resolvers@^2.9.11": - version "2.9.11" - resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" - integrity sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ== - "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -10193,11 +10188,6 @@ prop-types@^15.0.0, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.2, object-assign "^4.1.1" react-is "^16.13.1" -property-expr@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" - integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -11683,11 +11673,6 @@ through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -tiny-case@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" - integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== - tiny-glob@^0.2.9: version "0.2.9" resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" @@ -11737,11 +11722,6 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -toposort@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" - integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg== - totalist@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" @@ -11908,11 +11888,6 @@ type-fest@^1.0.1, type-fest@^1.2.1, type-fest@^1.2.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.19.0: - version "2.19.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" - integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== - type-fest@^3.0.0: version "3.5.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.5.2.tgz#16ff97c5dc1fd6bd6d50ef3c6ba92cc9c1add859" @@ -12569,16 +12544,6 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -yup@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/yup/-/yup-1.0.2.tgz#1cf485f407f77e0407b450311f2981a1e66f7c58" - integrity sha512-Lpi8nITFKjWtCoK3yQP8MUk78LJmHWqbFd0OOMXTar+yjejlQ4OIIoZgnTW1bnEUKDw6dZBcy3/IdXnt2KDUow== - dependencies: - property-expr "^2.0.5" - tiny-case "^1.0.3" - toposort "^2.0.2" - type-fest "^2.19.0" - zip-stream@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79" From b6ce0da8a17cb7dde7fff647abc9f2c914486ec5 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 2 Mar 2023 20:40:31 +0000 Subject: [PATCH 15/51] Show validation errors, in-progress form reset --- .../toolpad-components/src/DatePicker.tsx | 50 ++++++++++++------- .../toolpad-components/src/FilePicker.tsx | 10 +++- packages/toolpad-components/src/Form.tsx | 50 ++++++++++++++++--- packages/toolpad-components/src/Select.tsx | 50 +++++++++++++------ packages/toolpad-components/src/TextField.tsx | 12 +++-- 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 00371eab7da..d3ffee1129c 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -5,6 +5,7 @@ import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/D import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; +import { Controller, FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; import { FormContext } from './Form'; @@ -79,7 +80,10 @@ export interface DatePickerProps function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { const nodeRuntime = useNode(); + const nodeName = rest.name || nodeRuntime?.nodeName; + const { form, validationRules } = React.useContext(FormContext); + const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( (newValue: Dayjs | null) => { @@ -92,26 +96,38 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); - const nodeName = rest.name || nodeRuntime?.nodeName; + const datePickerProps: DesktopDatePickerProps = { + ...rest, + inputFormat: format || 'L', + value, + onChange: handleChange, + renderInput: (params) => ( + + ), + }; return ( - ( - - )} - /> + {form && nodeName ? ( + } + /> + ) : ( + + )} ); } diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 34376fd9355..688e215b280 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; +import { FieldError } from 'react-hook-form'; import { FormContext } from './Form'; interface FullFile { @@ -36,7 +37,10 @@ const readFile = async (file: Blob): Promise => { function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { const nodeRuntime = useNode(); + const nodeName = rest.name || nodeRuntime?.nodeName; + const { form, validationRules } = React.useContext(FormContext); + const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = async (changeEvent: React.ChangeEvent) => { const filesPromises = Array.from(changeEvent.target.files || []).map(async (file) => { @@ -55,8 +59,6 @@ function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { onChange(files); }; - const nodeName = rest.name || nodeRuntime?.nodeName; - return ( ); } diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 2480944a87b..62f594aacd8 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Container, ContainerProps, Box } from '@mui/material'; +import { Container, ContainerProps, Box, Stack } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; import { useForm, FieldValues, RegisterOptions } from 'react-hook-form'; @@ -7,7 +7,10 @@ import { SX_PROP_HELPER_TEXT } from './constants'; type FormValidationRules = Record< string, - Pick, 'required' | 'min' | 'max' | 'maxLength' | 'minLength'> + Pick< + RegisterOptions, + 'required' | 'min' | 'max' | 'maxLength' | 'minLength' | 'pattern' + > >; export const FormContext = React.createContext<{ @@ -23,14 +26,24 @@ interface FormProps extends ContainerProps { onChange: (newValue: FieldValues) => void; validationRules: FormValidationRules; onSubmit: (data?: FieldValues) => unknown | Promise; + hasResetButton: boolean; } -function Form({ children, onChange, onSubmit, validationRules, sx, ...rest }: FormProps) { +function Form({ + children, + onChange, + onSubmit, + validationRules, + hasResetButton, + sx, + ...rest +}: FormProps) { const form = useForm(); const handleSubmit = React.useCallback(async () => { await onSubmit(); - }, [onSubmit]); + form.reset(); + }, [form, onSubmit]); // Set initial form values React.useEffect(() => { @@ -44,9 +57,15 @@ function Form({ children, onChange, onSubmit, validationRules, sx, ...rest }: Fo return () => formSubscription.unsubscribe(); }, [form, onChange]); + const handleReset = React.useCallback(() => { + form.reset(); + }, [form]); + const formContextValue = React.useMemo( () => ({ form, validationRules }), - [form, validationRules], + // form never changes so use formState as dependency to update context when form state changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [form, form.formState, validationRules], ); return ( @@ -55,9 +74,20 @@ function Form({ children, onChange, onSubmit, validationRules, sx, ...rest }: Fo
{children} - - Submit - + + {hasResetButton ? ( + + Reset + + ) : null} + + {form.formState.isSubmitting ? 'Submitting…' : 'Submit'} + +
@@ -84,6 +114,10 @@ export default createComponent(Form, { helperText: 'Add logic to be executed when the user submits the form.', typeDef: { type: 'event' }, }, + hasResetButton: { + helperText: 'Show button to reset form values.', + typeDef: { type: 'boolean', default: false }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index dfedeca0fef..0574629f4e9 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; +import { Controller, FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; import { FormContext } from './Form'; @@ -19,7 +20,10 @@ export type SelectProps = Omit & { function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProps) { const nodeRuntime = useNode(); + const nodeName = rest.name || nodeRuntime?.nodeName; + const { form, validationRules } = React.useContext(FormContext); + const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( (event: React.ChangeEvent) => { @@ -30,19 +34,22 @@ function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProp const id = React.useId(); - const nodeName = rest.name || nodeRuntime?.nodeName; + const selectProps: TextFieldProps = { + ...rest, + value, + onChange: handleChange, + select: true, + sx: { ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }, + fullWidth, + ...(form && { + error: Boolean(fieldError), + helperText: (fieldError as FieldError)?.message || '', + }), + }; - return ( - - {options.map((option, i) => { + const renderedOptions = React.useMemo( + () => + options.map((option, i) => { const parsedOption: SelectOption = option && typeof option === 'object' ? option : { value: String(option) }; return ( @@ -50,8 +57,23 @@ function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProp {String(parsedOption.label ?? parsedOption.value)} ); - })} - + }), + [id, options], + ); + + return form && nodeName ? ( + ( + + {renderedOptions} + + )} + /> + ) : ( + {renderedOptions} ); } diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 6bff1e712c1..987b11ec585 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -5,6 +5,7 @@ import { BoxProps, } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; +import { FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; import { FormContext } from './Form'; @@ -19,7 +20,10 @@ export type TextFieldProps = Omit & { function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldProps) { const nodeRuntime = useNode(); + const nodeName = rest.name || nodeRuntime?.nodeName; + const { form, validationRules } = React.useContext(FormContext); + const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( (event: React.ChangeEvent) => { @@ -28,13 +32,15 @@ function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldPro [onChange], ); - const nodeName = rest.name || nodeRuntime?.nodeName; - return ( ); From 9211a7c25a6b97d058a036754a0b93b9760684d6 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 3 Mar 2023 18:25:26 +0000 Subject: [PATCH 16/51] Fix text, file and date components in forms --- .../toolpad-components/src/DatePicker.tsx | 20 +++++++++++++++---- .../toolpad-components/src/FilePicker.tsx | 12 ++++++++--- packages/toolpad-components/src/Form.tsx | 13 +++++++----- packages/toolpad-components/src/TextField.tsx | 16 +++++++++++++-- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index d3ffee1129c..f5997795a38 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -82,18 +82,29 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, validationRules } = React.useContext(FormContext); + const { form, fieldValues, validationRules } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( (newValue: Dayjs | null) => { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format const stringValue = newValue?.format('YYYY-MM-DD') || ''; - onChange(stringValue); + + if (form && nodeName) { + form.setValue(nodeName, stringValue, { shouldValidate: true, shouldDirty: true }); + } else { + onChange(stringValue); + } }, - [onChange], + [form, nodeName, onChange], ); + React.useEffect(() => { + if (form && nodeName) { + onChange(fieldValues[nodeName] || null); + } + }, [fieldValues, form, nodeName, onChange]); + const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); const datePickerProps: DesktopDatePickerProps = { @@ -122,8 +133,9 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { } + render={() => } /> ) : ( diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 688e215b280..36225e0d874 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -34,12 +34,12 @@ const readFile = async (file: Blob): Promise => { }); }; -function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { +function FilePicker({ multiple, value, onChange, ...rest }: FilePickerProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, validationRules } = React.useContext(FormContext); + const { form, fieldValues, validationRules } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = async (changeEvent: React.ChangeEvent) => { @@ -59,16 +59,22 @@ function FilePicker({ multiple, onChange, ...rest }: FilePickerProps) { onChange(files); }; + React.useEffect(() => { + if (fieldValues && nodeName) { + onChange(fieldValues[nodeName]); + } + }, [fieldValues, nodeName, onChange]); + return ( | null; + fieldValues: FieldValues; validationRules: FormValidationRules; }>({ form: null, + fieldValues: {}, validationRules: {}, }); @@ -31,6 +33,7 @@ interface FormProps extends ContainerProps { function Form({ children, + value, onChange, onSubmit, validationRules, @@ -51,8 +54,8 @@ function Form({ }, [form, onChange]); React.useEffect(() => { - const formSubscription = form.watch((value) => { - onChange(value); + const formSubscription = form.watch((newValue) => { + onChange(newValue); }); return () => formSubscription.unsubscribe(); }, [form, onChange]); @@ -62,10 +65,10 @@ function Form({ }, [form]); const formContextValue = React.useMemo( - () => ({ form, validationRules }), - // form never changes so use formState as dependency to update context when form state changes + () => ({ form, fieldValues: value, validationRules }), + // form never changes so also use formState as dependency to update context when form state changes // eslint-disable-next-line react-hooks/exhaustive-deps - [form, form.formState, validationRules], + [form, form.formState, validationRules, value], ); return ( diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 987b11ec585..2d24fd11240 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -22,16 +22,28 @@ function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldPro const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, validationRules } = React.useContext(FormContext); + const { form, fieldValues, validationRules } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( (event: React.ChangeEvent) => { - onChange(event.target.value); + const newValue = event.target.value; + onChange(newValue); }, [onChange], ); + React.useEffect(() => { + if (form && nodeName) { + let newValue = fieldValues[nodeName] || defaultValue; + if (!newValue && defaultValue) { + newValue = defaultValue; + form.setValue(nodeName, defaultValue); + } + onChange(newValue); + } + }, [defaultValue, fieldValues, form, nodeName, onChange]); + return ( Date: Fri, 3 Mar 2023 18:29:37 +0000 Subject: [PATCH 17/51] Fix date component --- packages/toolpad-components/src/DatePicker.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index f5997795a38..11c3ca3196a 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -101,9 +101,14 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { React.useEffect(() => { if (form && nodeName) { - onChange(fieldValues[nodeName] || null); + let newValue = fieldValues[nodeName] || rest.defaultValue || null; + if (!newValue && rest.defaultValue) { + newValue = rest.defaultValue; + form.setValue(nodeName, rest.defaultValue); + } + onChange(newValue); } - }, [fieldValues, form, nodeName, onChange]); + }, [fieldValues, form, nodeName, onChange, rest.defaultValue]); const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); @@ -133,7 +138,6 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { } /> From a4ecfb8c55e5f7555c096c260248adac48f89e18 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 3 Mar 2023 18:33:41 +0000 Subject: [PATCH 18/51] Extra fix --- packages/toolpad-components/src/DatePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 11c3ca3196a..9b4fdd28c4a 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -115,7 +115,7 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { const datePickerProps: DesktopDatePickerProps = { ...rest, inputFormat: format || 'L', - value, + value: value || null, onChange: handleChange, renderInput: (params) => ( Date: Mon, 13 Mar 2023 17:39:31 +0000 Subject: [PATCH 19/51] Fix default values with resets in all form input components --- .../toolpad-components/src/DatePicker.tsx | 16 +++-- .../toolpad-components/src/FilePicker.tsx | 46 ++++++++------ packages/toolpad-components/src/Form.tsx | 9 ++- packages/toolpad-components/src/Select.tsx | 60 ++++++++++--------- packages/toolpad-components/src/TextField.tsx | 11 ++-- 5 files changed, 84 insertions(+), 58 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 9b4fdd28c4a..b0b74098b0c 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -99,16 +99,20 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { [form, nodeName, onChange], ); + const isInitialForm = Object.keys(fieldValues).length === 0; + React.useEffect(() => { if (form && nodeName) { - let newValue = fieldValues[nodeName] || rest.defaultValue || null; - if (!newValue && rest.defaultValue) { - newValue = rest.defaultValue; - form.setValue(nodeName, rest.defaultValue); + if (rest.defaultValue && isInitialForm) { + const defaultValue = rest.defaultValue || null; + + onChange(defaultValue as string); + form.setValue(nodeName, defaultValue); + } else { + onChange(fieldValues[nodeName]); } - onChange(newValue); } - }, [fieldValues, form, nodeName, onChange, rest.defaultValue]); + }, [fieldValues, form, isInitialForm, nodeName, onChange, rest.defaultValue]); const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 36225e0d874..2ea129c8d50 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; -import { FieldError } from 'react-hook-form'; +import { Controller, FieldError } from 'react-hook-form'; import { FormContext } from './Form'; interface FullFile { @@ -56,7 +56,11 @@ function FilePicker({ multiple, value, onChange, ...rest }: FilePickerProps) { const files = await Promise.all(filesPromises); - onChange(files); + if (form && nodeName) { + form.setValue(nodeName, files, { shouldValidate: true, shouldDirty: true }); + } else { + onChange(files); + } }; React.useEffect(() => { @@ -65,22 +69,30 @@ function FilePicker({ multiple, value, onChange, ...rest }: FilePickerProps) { } }, [fieldValues, nodeName, onChange]); - return ( - } /> + ) : ( + ); } diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index fa758d35120..d2cc3ee2312 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -35,18 +35,23 @@ function Form({ children, value, onChange, - onSubmit, + onSubmit = () => {}, validationRules, hasResetButton, sx, ...rest }: FormProps) { const form = useForm(); + const { isSubmitSuccessful } = form.formState; const handleSubmit = React.useCallback(async () => { await onSubmit(); + }, [onSubmit]); + + // Reset form in effect as suggested in https://react-hook-form.com/api/useform/reset/ + React.useEffect(() => { form.reset(); - }, [form, onSubmit]); + }, [form, isSubmitSuccessful]); // Set initial form values React.useEffect(() => { diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 0574629f4e9..1f4077e043e 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; -import { Controller, FieldError } from 'react-hook-form'; +import { FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; import { FormContext } from './Form'; @@ -17,12 +17,12 @@ export type SelectProps = Omit & { name: string; }; -function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProps) { +function Select({ options, value, onChange, fullWidth, sx, defaultValue, ...rest }: SelectProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, validationRules } = React.useContext(FormContext); + const { form, fieldValues, validationRules } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( @@ -34,18 +34,17 @@ function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProp const id = React.useId(); - const selectProps: TextFieldProps = { - ...rest, - value, - onChange: handleChange, - select: true, - sx: { ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }, - fullWidth, - ...(form && { - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - }), - }; + const isInitialForm = Object.keys(fieldValues).length === 0; + + React.useEffect(() => { + if (form && nodeName) { + if (!fieldValues[nodeName] && defaultValue && isInitialForm) { + form.setValue(nodeName, defaultValue); + } else { + onChange(fieldValues[nodeName]); + } + } + }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange]); const renderedOptions = React.useMemo( () => @@ -61,19 +60,24 @@ function Select({ options, value, onChange, fullWidth, sx, ...rest }: SelectProp [id, options], ); - return form && nodeName ? ( - ( - - {renderedOptions} - - )} - /> - ) : ( - {renderedOptions} + return ( + + {renderedOptions} + ); } diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 2d24fd11240..034a09c4247 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -33,16 +33,17 @@ function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldPro [onChange], ); + const isInitialForm = Object.keys(fieldValues).length === 0; + React.useEffect(() => { if (form && nodeName) { - let newValue = fieldValues[nodeName] || defaultValue; - if (!newValue && defaultValue) { - newValue = defaultValue; + if (!fieldValues[nodeName] && defaultValue && isInitialForm) { form.setValue(nodeName, defaultValue); + } else { + onChange(fieldValues[nodeName]); } - onChange(newValue); } - }, [defaultValue, fieldValues, form, nodeName, onChange]); + }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange]); return ( Date: Mon, 13 Mar 2023 18:59:24 +0000 Subject: [PATCH 20/51] Run validation when validation rules change --- packages/toolpad-components/src/Form.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index d2cc3ee2312..3590544f36b 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -69,6 +69,10 @@ function Form({ form.reset(); }, [form]); + React.useEffect(() => { + form.trigger(); + }, [form, validationRules]); + const formContextValue = React.useMemo( () => ({ form, fieldValues: value, validationRules }), // form never changes so also use formState as dependency to update context when form state changes From 54845311d4233448362f810dd183d0d1328a2d07 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Wed, 15 Mar 2023 20:15:11 +0000 Subject: [PATCH 21/51] Add validation props to all inputs --- .../toolpad-components/src/DatePicker.tsx | 19 +++++- .../toolpad-components/src/FilePicker.tsx | 26 +++++++- packages/toolpad-components/src/Form.tsx | 26 +------- packages/toolpad-components/src/Select.tsx | 30 ++++++++- packages/toolpad-components/src/TextField.tsx | 65 ++++++++++++++++--- 5 files changed, 125 insertions(+), 41 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index b0b74098b0c..331cb5f016b 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -75,14 +75,16 @@ export interface DatePickerProps sx: any; defaultValue: string; name: string; + isRequired: boolean; + isInvalid: boolean; } -function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { +function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: DatePickerProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues, validationRules } = React.useContext(FormContext); + const { form, fieldValues } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( @@ -142,7 +144,10 @@ function DatePicker({ format, onChange, value, ...rest }: DatePickerProps) { !isInvalid || `${nodeName} is invalid.`, + }} render={() => } /> ) : ( @@ -199,6 +204,14 @@ export default createComponent(DatePicker, { helperText: 'The date picker is disabled.', typeDef: { type: 'boolean' }, }, + isRequired: { + helperText: 'Whether the date picker is required to have a value.', + typeDef: { type: 'boolean', default: false }, + }, + isInvalid: { + helperText: 'Whether the date picker value is invalid.', + typeDef: { type: 'boolean', default: false }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 2ea129c8d50..df2d711dde8 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -15,6 +15,8 @@ export type FilePickerProps = MuiTextFieldProps & { multiple: boolean; onChange: (files: FullFile[]) => void; name: string; + isRequired: boolean; + isInvalid: boolean; }; const readFile = async (file: Blob): Promise => { @@ -34,12 +36,19 @@ const readFile = async (file: Blob): Promise => { }); }; -function FilePicker({ multiple, value, onChange, ...rest }: FilePickerProps) { +function FilePicker({ + multiple, + value, + onChange, + isRequired, + isInvalid, + ...rest +}: FilePickerProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues, validationRules } = React.useContext(FormContext); + const { form, fieldValues } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = async (changeEvent: React.ChangeEvent) => { @@ -88,7 +97,10 @@ function FilePicker({ multiple, value, onChange, ...rest }: FilePickerProps) { !isInvalid || `${nodeName} is invalid.`, + }} render={() => } /> ) : ( @@ -120,6 +132,14 @@ export default createComponent(FilePicker, { helperText: 'Whether the FilePicker is disabled.', typeDef: { type: 'boolean' }, }, + isRequired: { + helperText: 'Whether the FilePicker is required to have a value.', + typeDef: { type: 'boolean', default: false }, + }, + isInvalid: { + helperText: 'Whether the FilePicker value is invalid.', + typeDef: { type: 'boolean', default: false }, + }, sx: { typeDef: { type: 'object' }, }, diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 3590544f36b..2a108b00d6e 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -2,31 +2,20 @@ import * as React from 'react'; import { Container, ContainerProps, Box, Stack } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; -import { useForm, FieldValues, RegisterOptions } from 'react-hook-form'; +import { useForm, FieldValues } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; -type FormValidationRules = Record< - string, - Pick< - RegisterOptions, - 'required' | 'min' | 'max' | 'maxLength' | 'minLength' | 'pattern' - > ->; - export const FormContext = React.createContext<{ form: ReturnType | null; fieldValues: FieldValues; - validationRules: FormValidationRules; }>({ form: null, fieldValues: {}, - validationRules: {}, }); interface FormProps extends ContainerProps { value: FieldValues; onChange: (newValue: FieldValues) => void; - validationRules: FormValidationRules; onSubmit: (data?: FieldValues) => unknown | Promise; hasResetButton: boolean; } @@ -36,7 +25,6 @@ function Form({ value, onChange, onSubmit = () => {}, - validationRules, hasResetButton, sx, ...rest @@ -69,15 +57,11 @@ function Form({ form.reset(); }, [form]); - React.useEffect(() => { - form.trigger(); - }, [form, validationRules]); - const formContextValue = React.useMemo( - () => ({ form, fieldValues: value, validationRules }), + () => ({ form, fieldValues: value }), // form never changes so also use formState as dependency to update context when form state changes // eslint-disable-next-line react-hooks/exhaustive-deps - [form, form.formState, validationRules, value], + [form, form.formState, value], ); return ( @@ -118,10 +102,6 @@ export default createComponent(Form, { typeDef: { type: 'object', default: {} }, onChangeProp: 'onChange', }, - validationRules: { - helperText: 'Validation rules for form fields.', - typeDef: { type: 'object', default: {} }, - }, onSubmit: { helperText: 'Add logic to be executed when the user submits the form.', typeDef: { type: 'event' }, diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 1f4077e043e..3d34c8df78c 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -15,14 +15,26 @@ export type SelectProps = Omit & { onChange: (newValue: string) => void; options: (string | SelectOption)[]; name: string; + isRequired: boolean; + isInvalid: boolean; }; -function Select({ options, value, onChange, fullWidth, sx, defaultValue, ...rest }: SelectProps) { +function Select({ + options, + value, + onChange, + fullWidth, + sx, + defaultValue, + isRequired, + isInvalid, + ...rest +}: SelectProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues, validationRules } = React.useContext(FormContext); + const { form, fieldValues } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( @@ -39,6 +51,7 @@ function Select({ options, value, onChange, fullWidth, sx, defaultValue, ...rest React.useEffect(() => { if (form && nodeName) { if (!fieldValues[nodeName] && defaultValue && isInitialForm) { + onChange(defaultValue); form.setValue(nodeName, defaultValue); } else { onChange(fieldValues[nodeName]); @@ -71,7 +84,10 @@ function Select({ options, value, onChange, fullWidth, sx, defaultValue, ...rest fullWidth {...(form && nodeName && { - ...form.register(nodeName, validationRules[nodeName]), + ...form.register(nodeName, { + required: isRequired ? `${nodeName} is required.` : false, + validate: () => !isInvalid || `${nodeName} is invalid.`, + }), error: Boolean(fieldError), helperText: (fieldError as FieldError)?.message || '', })} @@ -131,6 +147,14 @@ export default createComponent(Select, { helperText: 'Whether the select is disabled.', typeDef: { type: 'boolean' }, }, + isRequired: { + helperText: 'Whether the select is required to have a value.', + typeDef: { type: 'boolean', default: false }, + }, + isInvalid: { + helperText: 'Whether the select value is invalid.', + typeDef: { type: 'boolean', default: false }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 034a09c4247..ffa91fb9a15 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -15,14 +15,28 @@ export type TextFieldProps = Omit & { alignItems?: BoxProps['alignItems']; justifyContent?: BoxProps['justifyContent']; name: string; + isRequired: boolean; + minLength: number; + maxLength: number; + isInvalid: boolean; }; -function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldProps) { +function TextField({ + defaultValue, + onChange, + value, + ref, + isRequired, + minLength, + maxLength, + isInvalid, + ...rest +}: TextFieldProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues, validationRules } = React.useContext(FormContext); + const { form, fieldValues } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; const handleChange = React.useCallback( @@ -38,6 +52,7 @@ function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldPro React.useEffect(() => { if (form && nodeName) { if (!fieldValues[nodeName] && defaultValue && isInitialForm) { + onChange(defaultValue as string); form.setValue(nodeName, defaultValue); } else { onChange(fieldValues[nodeName]); @@ -48,13 +63,29 @@ function TextField({ defaultValue, onChange, value, ref, ...rest }: TextFieldPro return ( !isInvalid || `${nodeName} is invalid.`, + }), + error: Boolean(fieldError), + helperText: (fieldError as FieldError)?.message || '', + })} /> ); } @@ -98,6 +129,22 @@ export default createComponent(TextField, { helperText: 'Whether the input is disabled.', typeDef: { type: 'boolean' }, }, + isRequired: { + helperText: 'Whether the input is required to have a value.', + typeDef: { type: 'boolean', default: false }, + }, + minLength: { + helperText: 'Minimum value length.', + typeDef: { type: 'number', minimum: 0, maximum: 512, default: 0 }, + }, + maxLength: { + helperText: 'Maximum value length.', + typeDef: { type: 'number', minimum: 0, maximum: 512, default: 0 }, + }, + isInvalid: { + helperText: 'Whether the input value is invalid.', + typeDef: { type: 'boolean', default: false }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, From 6a7d9878483dfa7b76518f8d4281d8dc8a94f939 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 16:41:33 +0000 Subject: [PATCH 22/51] Allow validation outside forms --- .../toolpad-app/src/runtime/ToolpadApp.tsx | 1 - .../toolpad-components/src/DatePicker.tsx | 23 ++++++- .../toolpad-components/src/FilePicker.tsx | 23 ++++++- packages/toolpad-components/src/Form.tsx | 62 +++++++++++-------- packages/toolpad-components/src/Select.tsx | 25 +++++++- packages/toolpad-components/src/TextField.tsx | 23 ++++++- 6 files changed, 120 insertions(+), 37 deletions(-) diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index fdba6711a31..8b9e410def7 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -998,7 +998,6 @@ function RenderedPage({ nodeId }: RenderedNodeProps) { childNodeGroups={{ children }} Component={PageRootComponent} /> - {queries.map((node) => ( ))} diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 331cb5f016b..52405be53f0 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -7,7 +7,7 @@ import { createComponent, useNode } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; import { Controller, FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; -import { FormContext } from './Form'; +import Form, { FormContext } from './Form'; const LOCALE_LOADERS = new Map([ ['en', () => import('dayjs/locale/en')], @@ -157,7 +157,26 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: ); } -export default createComponent(DatePicker, { +function FormWrappedDatePicker(props: DatePickerProps) { + const { form } = React.useContext(FormContext); + + const [componentFormValue, setComponentFormValue] = React.useState({}); + + return form ? ( + + ) : ( +
+ + + ); +} + +export default createComponent(FormWrappedDatePicker, { helperText: 'The MUI X [Date picker](https://mui.com/x/react-date-pickers/date-picker/) component.\n\nThe date picker lets the user select a date.', argTypes: { diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index df2d711dde8..c8a401352ed 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Controller, FieldError } from 'react-hook-form'; -import { FormContext } from './Form'; +import Form, { FormContext } from './Form'; interface FullFile { name: string; @@ -108,7 +108,26 @@ function FilePicker({ ); } -export default createComponent(FilePicker, { +function FormWrappedFilePicker(props: FilePickerProps) { + const { form } = React.useContext(FormContext); + + const [componentFormValue, setComponentFormValue] = React.useState({}); + + return form ? ( + + ) : ( +
+ + + ); +} + +export default createComponent(FormWrappedFilePicker, { helperText: 'File picker component.\nIt allows users to take select and read files.', argTypes: { value: { diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 2a108b00d6e..4a726801743 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Container, ContainerProps, Box, Stack } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; -import { useForm, FieldValues } from 'react-hook-form'; +import { useForm, FieldValues, ValidationMode } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; export const FormContext = React.createContext<{ @@ -16,8 +16,10 @@ export const FormContext = React.createContext<{ interface FormProps extends ContainerProps { value: FieldValues; onChange: (newValue: FieldValues) => void; - onSubmit: (data?: FieldValues) => unknown | Promise; - hasResetButton: boolean; + onSubmit?: (data?: FieldValues) => unknown | Promise; + hasChrome?: boolean; + mode?: keyof ValidationMode | undefined; + hasResetButton?: boolean; } function Form({ @@ -25,11 +27,13 @@ function Form({ value, onChange, onSubmit = () => {}, - hasResetButton, + hasChrome = true, + hasResetButton = false, + mode = 'onSubmit', sx, ...rest }: FormProps) { - const form = useForm(); + const form = useForm({ mode }); const { isSubmitSuccessful } = form.formState; const handleSubmit = React.useCallback(async () => { @@ -65,29 +69,33 @@ function Form({ ); return ( - - -
- {children} - - - {hasResetButton ? ( - - Reset + + {hasChrome ? ( + + + {children} + + + {hasResetButton ? ( + + Reset + + ) : null} + + {form.formState.isSubmitting ? 'Submitting…' : 'Submit'} - ) : null} - - {form.formState.isSubmitting ? 'Submitting…' : 'Submit'} - - - - - -
+ + + + + ) : ( + children + )} + ); } diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 3d34c8df78c..c267bb3c0da 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -3,7 +3,7 @@ import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; -import { FormContext } from './Form'; +import Form, { FormContext } from './Form'; export interface SelectOption { value: string; @@ -51,7 +51,7 @@ function Select({ React.useEffect(() => { if (form && nodeName) { if (!fieldValues[nodeName] && defaultValue && isInitialForm) { - onChange(defaultValue); + onChange(defaultValue as string); form.setValue(nodeName, defaultValue); } else { onChange(fieldValues[nodeName]); @@ -97,7 +97,26 @@ function Select({ ); } -export default createComponent(Select, { +function FormWrappedSelect(props: SelectProps) { + const { form } = React.useContext(FormContext); + + const [componentFormValue, setComponentFormValue] = React.useState({}); + + return form ? ( + + + ); +} + +export default createComponent(FormWrappedSelect, { helperText: 'The Select component lets you select a value from a set of options.', layoutDirection: 'both', loadingPropSource: ['value', 'options'], diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index ffa91fb9a15..3b0b1725210 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -7,7 +7,7 @@ import { import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; -import { FormContext } from './Form'; +import Form, { FormContext } from './Form'; export type TextFieldProps = Omit & { value: string; @@ -90,7 +90,26 @@ function TextField({ ); } -export default createComponent(TextField, { +function FormWrappedTextField(props: TextFieldProps) { + const { form } = React.useContext(FormContext); + + const [componentFormValue, setComponentFormValue] = React.useState({}); + + return form ? ( + + ) : ( +
+ + + ); +} + +export default createComponent(FormWrappedTextField, { helperText: 'The TextField component lets you input a text value.', layoutDirection: 'both', argTypes: { From 5764345f256b759b3a88b8d03c5266fa49854cf2 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 17:39:32 +0000 Subject: [PATCH 23/51] Add prop categories and separate validation props --- .../AppEditor/PageEditor/ComponentEditor.tsx | 44 +++++++++++-------- .../toolpad-components/src/DatePicker.tsx | 2 + .../toolpad-components/src/FilePicker.tsx | 2 + packages/toolpad-components/src/Select.tsx | 2 + packages/toolpad-components/src/TextField.tsx | 4 ++ 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentEditor.tsx index 55458f80fe8..f69468516b4 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentEditor.tsx @@ -1,5 +1,6 @@ import { Stack, styled, Typography, Divider } from '@mui/material'; import * as React from 'react'; +import * as _ from 'lodash-es'; import { ArgTypeDefinition, ArgTypeDefinitions, @@ -97,6 +98,11 @@ function ComponentPropsEditor

({ ); }, [bindings, node.id]); + const argTypesByCategory = _.groupBy( + Object.entries(componentConfig.argTypes || {}) as ExactEntriesOf>, + ([, propTypeDef]) => propTypeDef?.category || 'properties', + ); + return ( {hasLayoutControls ? ( @@ -121,24 +127,26 @@ function ComponentPropsEditor

({ ) : null} - - Properties: - - {( - Object.entries(componentConfig.argTypes || {}) as ExactEntriesOf> - ).map(([propName, propTypeDef]) => - propTypeDef && shouldRenderControl(propTypeDef, propName, props, componentConfig) ? ( -

- -
- ) : null, - )} + {Object.entries(argTypesByCategory).map(([category, argTypeEntries]) => ( + + + {category}: + + {argTypeEntries.map(([propName, propTypeDef]) => + propTypeDef && shouldRenderControl(propTypeDef, propName, props, componentConfig) ? ( +
+ +
+ ) : null, + )} +
+ ))} ); } diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 52405be53f0..5550a4f8a95 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -226,10 +226,12 @@ export default createComponent(FormWrappedDatePicker, { isRequired: { helperText: 'Whether the date picker is required to have a value.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, isInvalid: { helperText: 'Whether the date picker value is invalid.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, sx: { helperText: SX_PROP_HELPER_TEXT, diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index c8a401352ed..bb17e276a52 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -154,10 +154,12 @@ export default createComponent(FormWrappedFilePicker, { isRequired: { helperText: 'Whether the FilePicker is required to have a value.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, isInvalid: { helperText: 'Whether the FilePicker value is invalid.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, sx: { typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index c267bb3c0da..c8253409add 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -169,10 +169,12 @@ export default createComponent(FormWrappedSelect, { isRequired: { helperText: 'Whether the select is required to have a value.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, isInvalid: { helperText: 'Whether the select value is invalid.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, sx: { helperText: SX_PROP_HELPER_TEXT, diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 3b0b1725210..b10cb066500 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -151,18 +151,22 @@ export default createComponent(FormWrappedTextField, { isRequired: { helperText: 'Whether the input is required to have a value.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, minLength: { helperText: 'Minimum value length.', typeDef: { type: 'number', minimum: 0, maximum: 512, default: 0 }, + category: 'validation', }, maxLength: { helperText: 'Maximum value length.', typeDef: { type: 'number', minimum: 0, maximum: 512, default: 0 }, + category: 'validation', }, isInvalid: { helperText: 'Whether the input value is invalid.', typeDef: { type: 'boolean', default: false }, + category: 'validation', }, sx: { helperText: SX_PROP_HELPER_TEXT, From 55b3e85907e21446902150ef44269d8c04da43b8 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:15:43 +0000 Subject: [PATCH 24/51] fix slow text input --- .../toolpad-components/src/DatePicker.tsx | 10 +-- .../toolpad-components/src/FilePicker.tsx | 6 +- packages/toolpad-components/src/Select.tsx | 65 +++++++++++-------- packages/toolpad-components/src/TextField.tsx | 49 +++++++------- 4 files changed, 73 insertions(+), 57 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 5550a4f8a95..a7aeb4e7a7f 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -110,11 +110,11 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: onChange(defaultValue as string); form.setValue(nodeName, defaultValue); - } else { + } else if (value !== fieldValues[nodeName]) { onChange(fieldValues[nodeName]); } } - }, [fieldValues, form, isInitialForm, nodeName, onChange, rest.defaultValue]); + }, [fieldValues, form, isInitialForm, nodeName, onChange, rest.defaultValue, value]); const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); @@ -138,6 +138,8 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: ), }; + const datePickerElement = ; + return ( {form && nodeName ? ( @@ -148,10 +150,10 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: required: isRequired ? `${nodeName} is required.` : false, validate: () => !isInvalid || `${nodeName} is invalid.`, }} - render={() => } + render={() => datePickerElement} /> ) : ( - + datePickerElement )} ); diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index bb17e276a52..4d153f826dd 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -113,8 +113,10 @@ function FormWrappedFilePicker(props: FilePickerProps) { const [componentFormValue, setComponentFormValue] = React.useState({}); + const filePickerElement = ; + return form ? ( - + filePickerElement ) : (
- + {filePickerElement} ); } diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index c8253409add..1011307f53c 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; -import { FieldError } from 'react-hook-form'; +import { FieldError, Controller } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; import Form, { FormContext } from './Form'; @@ -39,9 +39,15 @@ function Select({ const handleChange = React.useCallback( (event: React.ChangeEvent) => { - onChange(event.target.value); + const newValue = event.target.value; + + if (form && nodeName) { + form.setValue(nodeName, newValue, { shouldValidate: true, shouldDirty: true }); + } else { + onChange(newValue); + } }, - [onChange], + [form, nodeName, onChange], ); const id = React.useId(); @@ -53,11 +59,11 @@ function Select({ if (!fieldValues[nodeName] && defaultValue && isInitialForm) { onChange(defaultValue as string); form.setValue(nodeName, defaultValue); - } else { + } else if (value !== fieldValues[nodeName]) { onChange(fieldValues[nodeName]); } } - }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange]); + }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); const renderedOptions = React.useMemo( () => @@ -73,27 +79,34 @@ function Select({ [id, options], ); - return ( - !isInvalid || `${nodeName} is invalid.`, - }), - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - })} - > - {renderedOptions} - + const selectProps = { + ...rest, + value, + onChange: handleChange, + defaultValue, + select: true, + sx: { ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }, + fullWidth: true, + ...(form && { + error: Boolean(fieldError), + helperText: (fieldError as FieldError)?.message || '', + }), + }; + + const selectElement = {renderedOptions}; + + return form && nodeName ? ( + !isInvalid || `${nodeName} is invalid.`, + }} + render={() => selectElement} + /> + ) : ( + selectElement ); } diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index b10cb066500..50270a9cc0a 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -54,38 +54,37 @@ function TextField({ if (!fieldValues[nodeName] && defaultValue && isInitialForm) { onChange(defaultValue as string); form.setValue(nodeName, defaultValue); - } else { + } else if (value !== fieldValues[nodeName]) { onChange(fieldValues[nodeName]); } } - }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange]); + }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); return ( !isInvalid || `${nodeName} is invalid.`, - }), - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - })} + {...(form && nodeName + ? { + ...form.register(nodeName, { + required: isRequired ? `${nodeName} is required.` : false, + minLength: minLength + ? { + value: minLength, + message: `${nodeName} must have at least ${minLength} characters.`, + } + : undefined, + maxLength: maxLength + ? { + value: maxLength, + message: `${nodeName} must have no more than ${maxLength} characters.`, + } + : undefined, + validate: () => !isInvalid || `${nodeName} is invalid.`, + }), + error: Boolean(fieldError), + helperText: (fieldError as FieldError)?.message || '', + } + : { value, onChange: handleChange })} /> ); } From 774aeb7bc5d3b54b3688d0d6f9c88b4bd4638ad4 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:48:15 +0000 Subject: [PATCH 25/51] Run validation when validation rules change (fixed) --- packages/toolpad-components/src/DatePicker.tsx | 10 ++++++++++ packages/toolpad-components/src/FilePicker.tsx | 10 ++++++++++ packages/toolpad-components/src/Select.tsx | 10 ++++++++++ packages/toolpad-components/src/TextField.tsx | 13 +++++++++++++ 4 files changed, 43 insertions(+) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index a7aeb4e7a7f..16b46e14d15 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -6,6 +6,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; import { Controller, FieldError } from 'react-hook-form'; +import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; import Form, { FormContext } from './Form'; @@ -116,6 +117,15 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: } }, [fieldValues, form, isInitialForm, nodeName, onChange, rest.defaultValue, value]); + const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); + const previousManualValidationPropsRef = React.useRef(validationProps); + React.useEffect(() => { + if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { + form.trigger(); + previousManualValidationPropsRef.current = validationProps; + } + }, [form, validationProps]); + const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); const datePickerProps: DesktopDatePickerProps = { diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 4d153f826dd..ab74a15c9e5 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Controller, FieldError } from 'react-hook-form'; +import * as _ from 'lodash-es'; import Form, { FormContext } from './Form'; interface FullFile { @@ -78,6 +79,15 @@ function FilePicker({ } }, [fieldValues, nodeName, onChange]); + const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); + const previousManualValidationPropsRef = React.useRef(validationProps); + React.useEffect(() => { + if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { + form.trigger(); + previousManualValidationPropsRef.current = validationProps; + } + }, [form, validationProps]); + const filePickerProps = { ...rest, type: 'file', diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 1011307f53c..b77a4f0b06d 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError, Controller } from 'react-hook-form'; +import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; import Form, { FormContext } from './Form'; @@ -65,6 +66,15 @@ function Select({ } }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); + const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); + const previousManualValidationPropsRef = React.useRef(validationProps); + React.useEffect(() => { + if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { + form.trigger(); + previousManualValidationPropsRef.current = validationProps; + } + }, [form, validationProps]); + const renderedOptions = React.useMemo( () => options.map((option, i) => { diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 50270a9cc0a..807b30a5e57 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -6,6 +6,7 @@ import { } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError } from 'react-hook-form'; +import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; import Form, { FormContext } from './Form'; @@ -60,6 +61,18 @@ function TextField({ } }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); + const validationProps = React.useMemo( + () => ({ isRequired, minLength, maxLength, isInvalid }), + [isInvalid, isRequired, maxLength, minLength], + ); + const previousManualValidationPropsRef = React.useRef(validationProps); + React.useEffect(() => { + if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { + form.trigger(); + previousManualValidationPropsRef.current = validationProps; + } + }, [form, validationProps]); + return ( Date: Thu, 16 Mar 2023 19:08:46 +0000 Subject: [PATCH 26/51] Validate on submit in all input types --- packages/toolpad-components/src/DatePicker.tsx | 2 +- packages/toolpad-components/src/FilePicker.tsx | 2 +- packages/toolpad-components/src/Select.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 16b46e14d15..3bbdae58a43 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -94,7 +94,7 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: const stringValue = newValue?.format('YYYY-MM-DD') || ''; if (form && nodeName) { - form.setValue(nodeName, stringValue, { shouldValidate: true, shouldDirty: true }); + form.setValue(nodeName, stringValue); } else { onChange(stringValue); } diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index ab74a15c9e5..d70f22d6a2e 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -67,7 +67,7 @@ function FilePicker({ const files = await Promise.all(filesPromises); if (form && nodeName) { - form.setValue(nodeName, files, { shouldValidate: true, shouldDirty: true }); + form.setValue(nodeName, files); } else { onChange(files); } diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index b77a4f0b06d..2956aefc720 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -43,7 +43,7 @@ function Select({ const newValue = event.target.value; if (form && nodeName) { - form.setValue(nodeName, newValue, { shouldValidate: true, shouldDirty: true }); + form.setValue(nodeName, newValue); } else { onChange(newValue); } From 6d84e9b5cb189e0e2c549f2ecea8893efaad4122 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 19:34:49 +0000 Subject: [PATCH 27/51] Refactor with HOC --- .../toolpad-components/src/DatePicker.tsx | 21 ++-------------- .../toolpad-components/src/FilePicker.tsx | 23 ++--------------- packages/toolpad-components/src/Form.tsx | 25 +++++++++++++++++++ packages/toolpad-components/src/Select.tsx | 21 ++-------------- packages/toolpad-components/src/TextField.tsx | 21 ++-------------- packages/toolpad-core/src/types.ts | 4 +++ 6 files changed, 37 insertions(+), 78 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 3bbdae58a43..1fcdbe16075 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -8,7 +8,7 @@ import { Dayjs } from 'dayjs'; import { Controller, FieldError } from 'react-hook-form'; import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; -import Form, { FormContext } from './Form'; +import { FormContext, withComponentForm } from './Form'; const LOCALE_LOADERS = new Map([ ['en', () => import('dayjs/locale/en')], @@ -169,24 +169,7 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: ); } -function FormWrappedDatePicker(props: DatePickerProps) { - const { form } = React.useContext(FormContext); - - const [componentFormValue, setComponentFormValue] = React.useState({}); - - return form ? ( - - ) : ( -
- - - ); -} +const FormWrappedDatePicker = withComponentForm(DatePicker); export default createComponent(FormWrappedDatePicker, { helperText: diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index d70f22d6a2e..491f120c8db 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -3,7 +3,7 @@ import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from ' import { createComponent, useNode } from '@mui/toolpad-core'; import { Controller, FieldError } from 'react-hook-form'; import * as _ from 'lodash-es'; -import Form, { FormContext } from './Form'; +import { FormContext, withComponentForm } from './Form'; interface FullFile { name: string; @@ -118,26 +118,7 @@ function FilePicker({ ); } -function FormWrappedFilePicker(props: FilePickerProps) { - const { form } = React.useContext(FormContext); - - const [componentFormValue, setComponentFormValue] = React.useState({}); - - const filePickerElement = ; - - return form ? ( - filePickerElement - ) : ( -
- {filePickerElement} -
- ); -} +const FormWrappedFilePicker = withComponentForm(FilePicker); export default createComponent(FormWrappedFilePicker, { helperText: 'File picker component.\nIt allows users to take select and read files.', diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 4a726801743..47c212614f8 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -99,6 +99,31 @@ function Form({ ); } +export function withComponentForm

>( + InputComponent: React.ComponentType

, +) { + return function ComponentWithForm(props: P) { + const { form } = React.useContext(FormContext); + + const [componentFormValue, setComponentFormValue] = React.useState({}); + + const inputElement = ; + + return form ? ( + inputElement + ) : ( +

+ {inputElement} +
+ ); + }; +} + export default createComponent(Form, { argTypes: { children: { diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 2956aefc720..f7db29ec7a7 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -4,7 +4,7 @@ import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError, Controller } from 'react-hook-form'; import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; -import Form, { FormContext } from './Form'; +import { FormContext, withComponentForm } from './Form'; export interface SelectOption { value: string; @@ -120,24 +120,7 @@ function Select({ ); } -function FormWrappedSelect(props: SelectProps) { - const { form } = React.useContext(FormContext); - - const [componentFormValue, setComponentFormValue] = React.useState({}); - - return form ? ( - - - ); -} +const FormWrappedSelect = withComponentForm(Select); export default createComponent(FormWrappedSelect, { helperText: 'The Select component lets you select a value from a set of options.', diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 807b30a5e57..fd3fd543d36 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -8,7 +8,7 @@ import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError } from 'react-hook-form'; import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; -import Form, { FormContext } from './Form'; +import { FormContext, withComponentForm } from './Form'; export type TextFieldProps = Omit & { value: string; @@ -102,24 +102,7 @@ function TextField({ ); } -function FormWrappedTextField(props: TextFieldProps) { - const { form } = React.useContext(FormContext); - - const [componentFormValue, setComponentFormValue] = React.useState({}); - - return form ? ( - - ) : ( -
- - - ); -} +const FormWrappedTextField = withComponentForm(TextField); export default createComponent(FormWrappedTextField, { helperText: 'The TextField component lets you input a text value.', diff --git a/packages/toolpad-core/src/types.ts b/packages/toolpad-core/src/types.ts index fbe99ef7273..02505081c41 100644 --- a/packages/toolpad-core/src/types.ts +++ b/packages/toolpad-core/src/types.ts @@ -216,6 +216,10 @@ export interface ArgTypeDefinition

{ * @returns {boolean} a boolean value indicating whether the property should be visible or not */ visible?: ((props: P) => boolean) | boolean; + /** + * Name of category that this property belongs to. + */ + category?: string; } export type ArgTypeDefinitions

= { From 6dd6fcb8fed0702380e8b4560e2fb6ca8f9cb43d Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 20:04:17 +0000 Subject: [PATCH 28/51] Add visual customization options --- packages/toolpad-components/src/Form.tsx | 61 ++++++++++++++++++++---- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 47c212614f8..2fba862a848 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Container, ContainerProps, Box, Stack } from '@mui/material'; +import { Container, ContainerProps, Box, Stack, BoxProps } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; import { useForm, FieldValues, ValidationMode } from 'react-hook-form'; @@ -17,9 +17,13 @@ interface FormProps extends ContainerProps { value: FieldValues; onChange: (newValue: FieldValues) => void; onSubmit?: (data?: FieldValues) => unknown | Promise; - hasChrome?: boolean; - mode?: keyof ValidationMode | undefined; + formControlsAlign?: BoxProps['justifyContent']; + formControlsFullWidth: boolean; + submitButtonText?: string; + submitButtonLoadingText?: string; hasResetButton?: boolean; + mode?: keyof ValidationMode | undefined; + hasChrome?: boolean; } function Form({ @@ -27,9 +31,13 @@ function Form({ value, onChange, onSubmit = () => {}, - hasChrome = true, hasResetButton = false, + formControlsAlign = 'end', + formControlsFullWidth, + submitButtonText = 'Submit', + submitButtonLoadingText = 'Submitting…', mode = 'onSubmit', + hasChrome = true, sx, ...rest }: FormProps) { @@ -74,10 +82,25 @@ function Form({

{children} - - + + {hasResetButton ? ( - + Reset ) : null} @@ -85,8 +108,9 @@ function Form({ type="submit" variant="contained" loading={form.formState.isSubmitting} + sx={{ flex: formControlsFullWidth ? 1 : '0 1 auto' }} > - {form.formState.isSubmitting ? 'Submitting…' : 'Submit'} + {form.formState.isSubmitting ? submitButtonLoadingText : submitButtonText} @@ -139,6 +163,27 @@ export default createComponent(Form, { helperText: 'Add logic to be executed when the user submits the form.', typeDef: { type: 'event' }, }, + formControlsAlign: { + typeDef: { + type: 'string', + enum: ['start', 'center', 'end'], + default: 'end', + }, + label: 'Form controls alignment', + control: { type: 'HorizontalAlign' }, + }, + formControlsFullWidth: { + helperText: 'Whether the form controls should occupy all available horizontal space.', + typeDef: { type: 'boolean', default: false }, + }, + submitButtonText: { + helperText: 'Submit button text.', + typeDef: { type: 'string', default: 'Submit' }, + }, + submitButtonLoadingText: { + helperText: 'Submit button text while submitting form.', + typeDef: { type: 'string', default: 'Submitting…' }, + }, hasResetButton: { helperText: 'Show button to reset form values.', typeDef: { type: 'boolean', default: false }, From 67ea396e8e90fafcea2ee141095d6fe7d644af3f Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 16 Mar 2023 20:07:03 +0000 Subject: [PATCH 29/51] Fix types --- packages/toolpad-components/src/Form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 2fba862a848..b82fb519a61 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -18,7 +18,7 @@ interface FormProps extends ContainerProps { onChange: (newValue: FieldValues) => void; onSubmit?: (data?: FieldValues) => unknown | Promise; formControlsAlign?: BoxProps['justifyContent']; - formControlsFullWidth: boolean; + formControlsFullWidth?: boolean; submitButtonText?: string; submitButtonLoadingText?: string; hasResetButton?: boolean; From e23200b6375f696fc3495ac23d1e633b4959d28c Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 17 Mar 2023 19:09:36 +0000 Subject: [PATCH 30/51] Fix tests --- .../toolpad-components/src/DatePicker.tsx | 34 ++++++++++++++----- packages/toolpad-components/src/Select.tsx | 15 ++++++-- packages/toolpad-components/src/TextField.tsx | 27 +++++++++++---- test/integration/data-grid/index.spec.ts | 2 +- test/integration/editor/index.spec.ts | 12 +++---- test/integration/file-picker/index.spec.ts | 2 +- test/integration/propControls/index.spec.ts | 13 +++---- test/integration/undo-redo/index.spec.ts | 7 ++-- test/models/ToolpadEditor.ts | 4 --- 9 files changed, 77 insertions(+), 39 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 1fcdbe16075..5b59ea4adb4 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -68,7 +68,7 @@ function getSnapshot() { export interface DatePickerProps extends Omit, 'value' | 'onChange'> { value: string; - onChange: (newValue: string) => void; + onChange: (newValue: string | null) => void; format: string; fullWidth: boolean; variant: 'outlined' | 'filled' | 'standard'; @@ -80,7 +80,15 @@ export interface DatePickerProps isInvalid: boolean; } -function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: DatePickerProps) { +function DatePicker({ + format, + onChange, + value, + defaultValue, + isRequired, + isInvalid, + ...rest +}: DatePickerProps) { const nodeRuntime = useNode(); const nodeName = rest.name || nodeRuntime?.nodeName; @@ -102,20 +110,28 @@ function DatePicker({ format, onChange, value, isRequired, isInvalid, ...rest }: [form, nodeName, onChange], ); + const previousDefaultValueRef = React.useRef(defaultValue); + React.useEffect(() => { + if (form && nodeName && defaultValue !== previousDefaultValueRef.current) { + if (form && nodeName) { + form.setValue(nodeName, defaultValue); + } + previousDefaultValueRef.current = defaultValue; + } + }, [defaultValue, form, nodeName, onChange]); + const isInitialForm = Object.keys(fieldValues).length === 0; React.useEffect(() => { if (form && nodeName) { - if (rest.defaultValue && isInitialForm) { - const defaultValue = rest.defaultValue || null; - - onChange(defaultValue as string); - form.setValue(nodeName, defaultValue); + if (defaultValue && isInitialForm) { + onChange(defaultValue || null); + form.setValue(nodeName, defaultValue || null); } else if (value !== fieldValues[nodeName]) { - onChange(fieldValues[nodeName]); + onChange(fieldValues[nodeName] || null); } } - }, [fieldValues, form, isInitialForm, nodeName, onChange, rest.defaultValue, value]); + }, [fieldValues, form, isInitialForm, nodeName, onChange, defaultValue, value]); const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); const previousManualValidationPropsRef = React.useRef(validationProps); diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index f7db29ec7a7..443d5b95a1a 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -14,6 +14,7 @@ export interface SelectOption { export type SelectProps = Omit & { value: string; onChange: (newValue: string) => void; + defaultValue: string; options: (string | SelectOption)[]; name: string; isRequired: boolean; @@ -38,6 +39,8 @@ function Select({ const { form, fieldValues } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; + const id = React.useId(); + const handleChange = React.useCallback( (event: React.ChangeEvent) => { const newValue = event.target.value; @@ -51,14 +54,22 @@ function Select({ [form, nodeName, onChange], ); - const id = React.useId(); + const previousDefaultValueRef = React.useRef(defaultValue); + React.useEffect(() => { + if (form && nodeName && defaultValue !== previousDefaultValueRef.current) { + if (form && nodeName) { + form.setValue(nodeName, defaultValue); + } + previousDefaultValueRef.current = defaultValue; + } + }, [form, nodeName, onChange, defaultValue]); const isInitialForm = Object.keys(fieldValues).length === 0; React.useEffect(() => { if (form && nodeName) { if (!fieldValues[nodeName] && defaultValue && isInitialForm) { - onChange(defaultValue as string); + onChange(defaultValue); form.setValue(nodeName, defaultValue); } else if (value !== fieldValues[nodeName]) { onChange(fieldValues[nodeName]); diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index fd3fd543d36..00abe344bb7 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -13,6 +13,7 @@ import { FormContext, withComponentForm } from './Form'; export type TextFieldProps = Omit & { value: string; onChange: (newValue: string) => void; + defaultValue: string; alignItems?: BoxProps['alignItems']; justifyContent?: BoxProps['justifyContent']; name: string; @@ -26,7 +27,6 @@ function TextField({ defaultValue, onChange, value, - ref, isRequired, minLength, maxLength, @@ -43,20 +43,35 @@ function TextField({ const handleChange = React.useCallback( (event: React.ChangeEvent) => { const newValue = event.target.value; - onChange(newValue); + + if (form && nodeName) { + form.setValue(nodeName, newValue); + } else { + onChange(newValue); + } }, - [onChange], + [form, nodeName, onChange], ); + const previousDefaultValueRef = React.useRef(defaultValue); + React.useEffect(() => { + if (form && nodeName && defaultValue !== previousDefaultValueRef.current) { + if (form && nodeName) { + form.setValue(nodeName, defaultValue); + } + previousDefaultValueRef.current = defaultValue; + } + }, [form, nodeName, onChange, defaultValue]); + const isInitialForm = Object.keys(fieldValues).length === 0; React.useEffect(() => { if (form && nodeName) { if (!fieldValues[nodeName] && defaultValue && isInitialForm) { - onChange(defaultValue as string); - form.setValue(nodeName, defaultValue); + onChange(defaultValue || ''); + form.setValue(nodeName, defaultValue || ''); } else if (value !== fieldValues[nodeName]) { - onChange(fieldValues[nodeName]); + onChange(fieldValues[nodeName] || ''); } } }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); diff --git a/test/integration/data-grid/index.spec.ts b/test/integration/data-grid/index.spec.ts index c96b51ffa6d..d737acb7fee 100644 --- a/test/integration/data-grid/index.spec.ts +++ b/test/integration/data-grid/index.spec.ts @@ -15,7 +15,7 @@ test('Code component cell', async ({ page, api }) => { const editorModel = new ToolpadEditor(page); editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); await expect(editorModel.pageRoot.getByText('value: {"test":"value"}')).toBeVisible(); await expect( diff --git a/test/integration/editor/index.spec.ts b/test/integration/editor/index.spec.ts index 7e23f47340b..e480c5405db 100644 --- a/test/integration/editor/index.spec.ts +++ b/test/integration/editor/index.spec.ts @@ -46,7 +46,7 @@ test('can move elements in page', async ({ page, api }) => { await editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const canvasMoveElementHandleSelector = `:has-text("${TEXT_FIELD_COMPONENT_DISPLAY_NAME}")[draggable]`; @@ -64,8 +64,8 @@ test('can move elements in page', async ({ page, api }) => { await secondTextFieldLocator.focus(); await secondTextFieldLocator.fill('textField2'); - await expect(firstTextFieldLocator).toHaveAttribute('value', 'textField1'); - await expect(secondTextFieldLocator).toHaveAttribute('value', 'textField2'); + await expect(firstTextFieldLocator).toHaveValue('textField1'); + await expect(secondTextFieldLocator).toHaveValue('textField2'); await expect(canvasMoveElementHandleLocator).not.toBeVisible(); @@ -86,8 +86,8 @@ test('can move elements in page', async ({ page, api }) => { moveTargetY, ); - await expect(firstTextFieldLocator).toHaveAttribute('value', 'textField2'); - await expect(secondTextFieldLocator).toHaveAttribute('value', 'textField1'); + await expect(firstTextFieldLocator).toHaveValue('textField2'); + await expect(secondTextFieldLocator).toHaveValue('textField1'); }); test('can delete elements from page', async ({ page, api }) => { @@ -101,7 +101,7 @@ test('can delete elements from page', async ({ page, api }) => { await editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const canvasInputLocator = editorModel.appCanvas.locator('input'); const canvasRemoveElementButtonLocator = editorModel.appCanvas.locator( diff --git a/test/integration/file-picker/index.spec.ts b/test/integration/file-picker/index.spec.ts index 77ec115bff0..ff494d0e380 100644 --- a/test/integration/file-picker/index.spec.ts +++ b/test/integration/file-picker/index.spec.ts @@ -17,7 +17,7 @@ test('File picker component', async ({ page, api }) => { const editorModel = new ToolpadEditor(page); editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const filePicker = editorModel.pageRoot.locator('label'); diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts index 1a637e7c190..540660cb863 100644 --- a/test/integration/propControls/index.spec.ts +++ b/test/integration/propControls/index.spec.ts @@ -13,9 +13,7 @@ test('can control component prop values in properties control panel', async ({ p }); const editorModel = new ToolpadEditor(page); - await editorModel.goto(app.id); - await editorModel.pageRoot.waitFor(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -40,7 +38,7 @@ test('can control component prop values in properties control panel', async ({ p const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); expect(await valueControl.inputValue()).not.toBe(TEST_VALUE_1); await firstInputLocator.fill(TEST_VALUE_1); - expect(await valueControl.inputValue()).toBe(TEST_VALUE_1); + await expect(valueControl).toHaveValue(TEST_VALUE_1); // Change component prop values through controls const TEST_VALUE_2 = 'value2'; @@ -63,7 +61,7 @@ test('changing defaultValue resets controlled value', async ({ page, api }) => { const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const firstInput = editorModel.appCanvas.locator('input').nth(0); const secondInput = editorModel.appCanvas.locator('input').nth(1); @@ -96,13 +94,16 @@ test('cannot change controlled component prop values', async ({ page, api }) => }); const editorModel = new ToolpadEditor(page); - await editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const input = editorModel.appCanvas.locator('input').first(); await clickCenter(page, input); + await editorModel.componentEditor + .locator('h6:has-text("Text field")') + .waitFor({ state: 'visible' }); + const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); await expect(valueControl).toBeDisabled(); }); diff --git a/test/integration/undo-redo/index.spec.ts b/test/integration/undo-redo/index.spec.ts index 504d2b4328d..2c330646c15 100644 --- a/test/integration/undo-redo/index.spec.ts +++ b/test/integration/undo-redo/index.spec.ts @@ -14,8 +14,7 @@ test('test basic undo and redo', async ({ page, api }) => { const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); - - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -50,7 +49,7 @@ test('test batching text input actions into single undo entry', async ({ page, a const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const input = editorModel.appCanvas.locator('input').first(); @@ -89,7 +88,7 @@ test('test undo and redo through different pages', async ({ page, api }) => { const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); - await editorModel.waitForOverlay(); + await editorModel.pageRoot.waitFor(); const pageButton1 = editorModel.appCanvas.getByRole('button', { name: 'page1Button', diff --git a/test/models/ToolpadEditor.ts b/test/models/ToolpadEditor.ts index 617e78fd0cc..fe4c85571a2 100644 --- a/test/models/ToolpadEditor.ts +++ b/test/models/ToolpadEditor.ts @@ -109,10 +109,6 @@ export class ToolpadEditor { ]); } - waitForOverlay() { - return expect(this.pageOverlay).toBeVisible(); - } - async dragToAppCanvas( sourceSelector: string, isSourceInCanvas: boolean, From 93764f427ea49d2076a2d93507deedefad8b159f Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 17 Mar 2023 19:11:44 +0000 Subject: [PATCH 31/51] Restore spacing in tests --- test/integration/propControls/index.spec.ts | 2 ++ test/integration/undo-redo/index.spec.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts index 540660cb863..395353c2f88 100644 --- a/test/integration/propControls/index.spec.ts +++ b/test/integration/propControls/index.spec.ts @@ -14,6 +14,7 @@ test('can control component prop values in properties control panel', async ({ p const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); + await editorModel.pageRoot.waitFor(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -95,6 +96,7 @@ test('cannot change controlled component prop values', async ({ page, api }) => const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); + await editorModel.pageRoot.waitFor(); const input = editorModel.appCanvas.locator('input').first(); diff --git a/test/integration/undo-redo/index.spec.ts b/test/integration/undo-redo/index.spec.ts index 2c330646c15..1e8ac28b1da 100644 --- a/test/integration/undo-redo/index.spec.ts +++ b/test/integration/undo-redo/index.spec.ts @@ -14,6 +14,7 @@ test('test basic undo and redo', async ({ page, api }) => { const editorModel = new ToolpadEditor(page); await editorModel.goto(app.id); + await editorModel.pageRoot.waitFor(); const canvasInputLocator = editorModel.appCanvas.locator('input'); From 605d5f836ff802c9941ae9b96b17dc417f12f97c Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Mon, 20 Mar 2023 22:06:31 +0000 Subject: [PATCH 32/51] Refactor, fix bugs --- .../AppEditor/PageEditor/EditorCanvasHost.tsx | 2 +- packages/toolpad-components/src/Button.tsx | 4 + .../toolpad-components/src/DatePicker.tsx | 60 +++---- .../toolpad-components/src/FilePicker.tsx | 40 +++-- packages/toolpad-components/src/Form.tsx | 149 ++++++++++++++---- packages/toolpad-components/src/Select.tsx | 60 +++---- packages/toolpad-components/src/TextField.tsx | 118 +++++++------- packages/toolpad-core/src/index.tsx | 2 + 8 files changed, 234 insertions(+), 201 deletions(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx index 1c8bf46622a..9c8e96f47ff 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx @@ -112,7 +112,7 @@ export default function EditorCanvasHost({ const [editorOverlayRoot, setEditorOverlayRoot] = React.useState(null); const handleKeyDown = useEvent((event: KeyboardEvent) => { - const isZ = event.key.toLowerCase() === 'z'; + const isZ = !!event.key && event.key.toLowerCase() === 'z'; const undoShortcut = isZ && (event.metaKey || event.ctrlKey); const redoShortcut = undoShortcut && event.shiftKey; diff --git a/packages/toolpad-components/src/Button.tsx b/packages/toolpad-components/src/Button.tsx index 3dbb0712047..070fb5d219a 100644 --- a/packages/toolpad-components/src/Button.tsx +++ b/packages/toolpad-components/src/Button.tsx @@ -53,6 +53,10 @@ export default createComponent(Button, { helperText: 'Whether the button is disabled.', typeDef: { type: 'boolean' }, }, + type: { + helperText: 'Button HTML type', + typeDef: { type: 'string', enum: ['button', 'submit', 'reset'], default: 'button' }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 5b59ea4adb4..b63785f9dd7 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -6,9 +6,8 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Dayjs } from 'dayjs'; import { Controller, FieldError } from 'react-hook-form'; -import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; -import { FormContext, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form'; const LOCALE_LOADERS = new Map([ ['en', () => import('dayjs/locale/en')], @@ -93,55 +92,34 @@ function DatePicker({ const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues } = React.useContext(FormContext); + const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; + const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); + + const { onFormInputChange } = useFormInput({ + name: nodeName, + value, + onChange, + defaultValue, + emptyValue: null, + validationProps, + }); + const handleChange = React.useCallback( (newValue: Dayjs | null) => { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format const stringValue = newValue?.format('YYYY-MM-DD') || ''; - if (form && nodeName) { - form.setValue(nodeName, stringValue); + if (form) { + onFormInputChange(stringValue); } else { onChange(stringValue); } }, - [form, nodeName, onChange], + [form, onChange, onFormInputChange], ); - const previousDefaultValueRef = React.useRef(defaultValue); - React.useEffect(() => { - if (form && nodeName && defaultValue !== previousDefaultValueRef.current) { - if (form && nodeName) { - form.setValue(nodeName, defaultValue); - } - previousDefaultValueRef.current = defaultValue; - } - }, [defaultValue, form, nodeName, onChange]); - - const isInitialForm = Object.keys(fieldValues).length === 0; - - React.useEffect(() => { - if (form && nodeName) { - if (defaultValue && isInitialForm) { - onChange(defaultValue || null); - form.setValue(nodeName, defaultValue || null); - } else if (value !== fieldValues[nodeName]) { - onChange(fieldValues[nodeName] || null); - } - } - }, [fieldValues, form, isInitialForm, nodeName, onChange, defaultValue, value]); - - const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); - const previousManualValidationPropsRef = React.useRef(validationProps); - React.useEffect(() => { - if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { - form.trigger(); - previousManualValidationPropsRef.current = validationProps; - } - }, [form, validationProps]); - const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); const datePickerProps: DesktopDatePickerProps = { @@ -166,6 +144,8 @@ function DatePicker({ const datePickerElement = ; + const fieldDisplayName = rest.label || nodeName; + return ( {form && nodeName ? ( @@ -173,8 +153,8 @@ function DatePicker({ name={nodeName} control={form.control} rules={{ - required: isRequired ? `${nodeName} is required.` : false, - validate: () => !isInvalid || `${nodeName} is invalid.`, + required: isRequired ? `${fieldDisplayName} is required.` : false, + validate: () => !isInvalid || `${fieldDisplayName} is invalid.`, }} render={() => datePickerElement} /> diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 491f120c8db..873c867c022 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -2,8 +2,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Controller, FieldError } from 'react-hook-form'; -import * as _ from 'lodash-es'; -import { FormContext, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form'; interface FullFile { name: string; @@ -14,6 +13,7 @@ interface FullFile { export type FilePickerProps = MuiTextFieldProps & { multiple: boolean; + value: FullFile[]; onChange: (files: FullFile[]) => void; name: string; isRequired: boolean; @@ -49,9 +49,18 @@ function FilePicker({ const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues } = React.useContext(FormContext); + const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; + const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); + + const { onFormInputChange } = useFormInput({ + name: nodeName, + value, + onChange, + validationProps, + }); + const handleChange = async (changeEvent: React.ChangeEvent) => { const filesPromises = Array.from(changeEvent.target.files || []).map(async (file) => { const fullFile: FullFile = { @@ -66,28 +75,13 @@ function FilePicker({ const files = await Promise.all(filesPromises); - if (form && nodeName) { - form.setValue(nodeName, files); + if (form) { + onFormInputChange(files); } else { onChange(files); } }; - React.useEffect(() => { - if (fieldValues && nodeName) { - onChange(fieldValues[nodeName]); - } - }, [fieldValues, nodeName, onChange]); - - const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); - const previousManualValidationPropsRef = React.useRef(validationProps); - React.useEffect(() => { - if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { - form.trigger(); - previousManualValidationPropsRef.current = validationProps; - } - }, [form, validationProps]); - const filePickerProps = { ...rest, type: 'file', @@ -103,13 +97,15 @@ function FilePicker({ }), }; + const fieldDisplayName = rest.label || nodeName; + return form && nodeName ? ( !isInvalid || `${nodeName} is invalid.`, + required: isRequired ? `${fieldDisplayName} is required.` : false, + validate: () => !isInvalid || `${fieldDisplayName} is invalid.`, }} render={() => } /> diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index b82fb519a61..a486ef68add 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -3,6 +3,7 @@ import { Container, ContainerProps, Box, Stack, BoxProps } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; import { useForm, FieldValues, ValidationMode } from 'react-hook-form'; +import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; export const FormContext = React.createContext<{ @@ -20,10 +21,10 @@ interface FormProps extends ContainerProps { formControlsAlign?: BoxProps['justifyContent']; formControlsFullWidth?: boolean; submitButtonText?: string; - submitButtonLoadingText?: string; hasResetButton?: boolean; mode?: keyof ValidationMode | undefined; hasChrome?: boolean; + hideControls?: boolean; } function Form({ @@ -35,9 +36,9 @@ function Form({ formControlsAlign = 'end', formControlsFullWidth, submitButtonText = 'Submit', - submitButtonLoadingText = 'Submitting…', mode = 'onSubmit', hasChrome = true, + hideControls = false, sx, ...rest }: FormProps) { @@ -80,40 +81,44 @@ function Form({ {hasChrome ? ( - + {children} - - - {hasResetButton ? ( + + {hasResetButton ? ( + + Reset + + ) : null} - Reset + {submitButtonText} - ) : null} - - {form.formState.isSubmitting ? submitButtonLoadingText : submitButtonText} - - - + + + ) : null}
) : ( @@ -123,6 +128,84 @@ function Form({ ); } +interface UseFormInputInput { + name?: string | null; + value: V; + onChange: (newValue: V) => void; + emptyValue?: V; + defaultValue?: V; + validationProps: Record; +} + +interface UseFormInputPayload { + onFormInputChange: (newValue: V) => void; +} + +export function useFormInput({ + name, + value, + onChange, + emptyValue, + defaultValue, + validationProps, +}: UseFormInputInput): UseFormInputPayload { + const { form, fieldValues } = React.useContext(FormContext); + + const handleFormInputChange = React.useCallback( + (newValue: V) => { + if (form && name) { + form.setValue(name, newValue, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + onChange(newValue); + } + }, + [form, name, onChange], + ); + + const previousDefaultValueRef = React.useRef(defaultValue); + React.useEffect(() => { + if (form && name && defaultValue !== previousDefaultValueRef.current) { + if (form && name) { + form.setValue(name, defaultValue); + } + previousDefaultValueRef.current = defaultValue; + } + }, [form, name, onChange, defaultValue]); + + const isInitialForm = Object.keys(fieldValues).length === 0; + + React.useEffect(() => { + if (form && name) { + if (!fieldValues[name] && defaultValue && isInitialForm) { + onChange((defaultValue || emptyValue) as V); + form.setValue(name, defaultValue || emptyValue); + } else if (value !== fieldValues[name]) { + onChange(fieldValues[name] || emptyValue); + } + } + }, [defaultValue, emptyValue, fieldValues, form, isInitialForm, name, onChange, value]); + + const previousManualValidationPropsRef = React.useRef(validationProps); + React.useEffect(() => { + if ( + form && + name && + !_.isEqual(validationProps, previousManualValidationPropsRef.current) && + form.formState.dirtyFields[name] + ) { + form.trigger(name); + previousManualValidationPropsRef.current = validationProps; + } + }, [form, name, validationProps]); + + return { + onFormInputChange: handleFormInputChange, + }; +} + export function withComponentForm

>( InputComponent: React.ComponentType

, ) { @@ -180,14 +263,14 @@ export default createComponent(Form, { helperText: 'Submit button text.', typeDef: { type: 'string', default: 'Submit' }, }, - submitButtonLoadingText: { - helperText: 'Submit button text while submitting form.', - typeDef: { type: 'string', default: 'Submitting…' }, - }, hasResetButton: { helperText: 'Show button to reset form values.', typeDef: { type: 'boolean', default: false }, }, + hideControls: { + helperText: 'Hide form controls.', + typeDef: { type: 'boolean', default: false }, + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 443d5b95a1a..571e032f62d 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -2,9 +2,8 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError, Controller } from 'react-hook-form'; -import * as _ from 'lodash-es'; import { SX_PROP_HELPER_TEXT } from './constants'; -import { FormContext, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form'; export interface SelectOption { value: string; @@ -36,56 +35,34 @@ function Select({ const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues } = React.useContext(FormContext); + const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; + const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); + + const { onFormInputChange } = useFormInput({ + name: nodeName, + value, + onChange, + defaultValue, + validationProps, + }); + const id = React.useId(); const handleChange = React.useCallback( (event: React.ChangeEvent) => { const newValue = event.target.value; - if (form && nodeName) { - form.setValue(nodeName, newValue); + if (form) { + onFormInputChange(newValue); } else { onChange(newValue); } }, - [form, nodeName, onChange], + [form, onChange, onFormInputChange], ); - const previousDefaultValueRef = React.useRef(defaultValue); - React.useEffect(() => { - if (form && nodeName && defaultValue !== previousDefaultValueRef.current) { - if (form && nodeName) { - form.setValue(nodeName, defaultValue); - } - previousDefaultValueRef.current = defaultValue; - } - }, [form, nodeName, onChange, defaultValue]); - - const isInitialForm = Object.keys(fieldValues).length === 0; - - React.useEffect(() => { - if (form && nodeName) { - if (!fieldValues[nodeName] && defaultValue && isInitialForm) { - onChange(defaultValue); - form.setValue(nodeName, defaultValue); - } else if (value !== fieldValues[nodeName]) { - onChange(fieldValues[nodeName]); - } - } - }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); - - const validationProps = React.useMemo(() => ({ isRequired, isInvalid }), [isInvalid, isRequired]); - const previousManualValidationPropsRef = React.useRef(validationProps); - React.useEffect(() => { - if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { - form.trigger(); - previousManualValidationPropsRef.current = validationProps; - } - }, [form, validationProps]); - const renderedOptions = React.useMemo( () => options.map((option, i) => { @@ -104,7 +81,6 @@ function Select({ ...rest, value, onChange: handleChange, - defaultValue, select: true, sx: { ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }, fullWidth: true, @@ -116,13 +92,15 @@ function Select({ const selectElement = {renderedOptions}; + const fieldDisplayName = rest.label || nodeName; + return form && nodeName ? ( !isInvalid || `${nodeName} is invalid.`, + required: isRequired ? `${fieldDisplayName} is required.` : false, + validate: () => !isInvalid || `${fieldDisplayName} is invalid.`, }} render={() => selectElement} /> diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 00abe344bb7..02f0c89038b 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -5,10 +5,9 @@ import { BoxProps, } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; -import { FieldError } from 'react-hook-form'; -import * as _ from 'lodash-es'; +import { FieldError, Controller } from 'react-hook-form'; import { SX_PROP_HELPER_TEXT } from './constants'; -import { FormContext, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form'; export type TextFieldProps = Omit & { value: string; @@ -37,83 +36,74 @@ function TextField({ const nodeName = rest.name || nodeRuntime?.nodeName; - const { form, fieldValues } = React.useContext(FormContext); + const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; + const validationProps = React.useMemo( + () => ({ isRequired, minLength, maxLength, isInvalid }), + [isInvalid, isRequired, maxLength, minLength], + ); + + const { onFormInputChange } = useFormInput({ + name: nodeName, + value, + onChange, + emptyValue: '', + defaultValue, + validationProps, + }); + const handleChange = React.useCallback( (event: React.ChangeEvent) => { const newValue = event.target.value; - if (form && nodeName) { - form.setValue(nodeName, newValue); + if (form) { + onFormInputChange(newValue); } else { onChange(newValue); } }, - [form, nodeName, onChange], + [form, onChange, onFormInputChange], ); - const previousDefaultValueRef = React.useRef(defaultValue); - React.useEffect(() => { - if (form && nodeName && defaultValue !== previousDefaultValueRef.current) { - if (form && nodeName) { - form.setValue(nodeName, defaultValue); - } - previousDefaultValueRef.current = defaultValue; - } - }, [form, nodeName, onChange, defaultValue]); + const textFieldProps = { + ...rest, + value, + onChange: handleChange, + ...(form && { + error: Boolean(fieldError), + helperText: (fieldError as FieldError)?.message || '', + }), + }; - const isInitialForm = Object.keys(fieldValues).length === 0; + const textFieldElement = ; - React.useEffect(() => { - if (form && nodeName) { - if (!fieldValues[nodeName] && defaultValue && isInitialForm) { - onChange(defaultValue || ''); - form.setValue(nodeName, defaultValue || ''); - } else if (value !== fieldValues[nodeName]) { - onChange(fieldValues[nodeName] || ''); - } - } - }, [defaultValue, fieldValues, form, isInitialForm, nodeName, onChange, value]); - - const validationProps = React.useMemo( - () => ({ isRequired, minLength, maxLength, isInvalid }), - [isInvalid, isRequired, maxLength, minLength], - ); - const previousManualValidationPropsRef = React.useRef(validationProps); - React.useEffect(() => { - if (form && !_.isEqual(validationProps, previousManualValidationPropsRef.current)) { - form.trigger(); - previousManualValidationPropsRef.current = validationProps; - } - }, [form, validationProps]); + const fieldDisplayName = rest.label || nodeName; - return ( - !isInvalid || `${nodeName} is invalid.`, - }), - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - } - : { value, onChange: handleChange })} + return form && nodeName ? ( + !isInvalid || `${fieldDisplayName} is invalid.`, + }} + render={() => textFieldElement} /> + ) : ( + textFieldElement ); } diff --git a/packages/toolpad-core/src/index.tsx b/packages/toolpad-core/src/index.tsx index 553d8962f78..0582857edf4 100644 --- a/packages/toolpad-core/src/index.tsx +++ b/packages/toolpad-core/src/index.tsx @@ -20,5 +20,7 @@ export * from './types.js'; export * from './componentsContext.js'; +export * from './utils/collections'; + export { default as createQuery } from './createQuery.js'; export * from './createQuery.js'; From 1bbfcb1b1b697b8b88430e171124f25bbb51f767 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Tue, 21 Mar 2023 16:57:02 +0000 Subject: [PATCH 33/51] Improve shared form logic --- packages/toolpad-components/src/Form.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index a486ef68add..455ea37dc32 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -168,9 +168,8 @@ export function useFormInput({ const previousDefaultValueRef = React.useRef(defaultValue); React.useEffect(() => { if (form && name && defaultValue !== previousDefaultValueRef.current) { - if (form && name) { - form.setValue(name, defaultValue); - } + onChange(defaultValue as V); + form.setValue(name, defaultValue); previousDefaultValueRef.current = defaultValue; } }, [form, name, onChange, defaultValue]); From 6c7224f867acd1f72584c35b692a711508eef4b1 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Tue, 21 Mar 2023 17:35:51 +0000 Subject: [PATCH 34/51] Adjust date picker component --- packages/toolpad-components/src/DatePicker.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 01e06dbc76c..e2b73577b12 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -1,6 +1,10 @@ import * as React from 'react'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/DesktopDatePicker'; +import { + DesktopDatePicker, + DesktopDatePickerProps, + DesktopDatePickerSlotsComponentsProps, +} from '@mui/x-date-pickers/DesktopDatePicker'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent, useNode } from '@mui/toolpad-core'; import dayjs from 'dayjs'; @@ -129,11 +133,17 @@ function DatePicker({ [valueProp], ); - const datePickerProps: DesktopDatePickerProps = { + const defaultValue = React.useMemo( + () => (typeof defaultValueProp === 'string' ? dayjs(defaultValueProp) : defaultValueProp), + [defaultValueProp], + ); + + const datePickerProps: DesktopDatePickerProps = { ...rest, format: format || 'L', value: value || null, onChange: handleChange, + defaultValue, slotProps: { textField: { fullWidth: rest.fullWidth, @@ -145,7 +155,7 @@ function DatePicker({ helperText: (fieldError as FieldError)?.message || '', }), }, - }, + } as DesktopDatePickerSlotsComponentsProps, }; const datePickerElement = {...datePickerProps} />; From f41dcc53f28b2c76c2cadab984b44a4ddc0380c1 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 12:42:26 +0000 Subject: [PATCH 35/51] Fix merge --- test/integration/editor/index.spec.ts | 2 ++ test/integration/propControls/index.spec.ts | 23 --------------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/test/integration/editor/index.spec.ts b/test/integration/editor/index.spec.ts index f7280f269f7..5474215be22 100644 --- a/test/integration/editor/index.spec.ts +++ b/test/integration/editor/index.spec.ts @@ -13,6 +13,8 @@ test.describe('from new application', () => { test('can place new components from catalog', async ({ page }) => { const editorModel = new ToolpadEditor(page); + await editorModel.goto(); + await editorModel.pageRoot.waitFor(); const canvasInputLocator = editorModel.appCanvas.locator('input'); diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts index 85a8de34a75..900b097d85e 100644 --- a/test/integration/propControls/index.spec.ts +++ b/test/integration/propControls/index.spec.ts @@ -91,26 +91,3 @@ test.describe('default values', () => { await expect(secondInput).toHaveValue('New'); }); }); - -test('cannot change controlled component prop values', async ({ page, api }) => { - const dom = await readJsonFile(path.resolve(__dirname, './domInput.json')); - - const app = await api.mutation.createApp(`App ${generateId()}`, { - from: { kind: 'dom', dom }, - }); - - const editorModel = new ToolpadEditor(page); - await editorModel.goto(app.id); - - await editorModel.pageRoot.waitFor(); - - const input = editorModel.appCanvas.locator('input').first(); - await clickCenter(page, input); - - await editorModel.componentEditor - .locator('h6:has-text("Text field")') - .waitFor({ state: 'visible' }); - - const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); - await expect(valueControl).toBeDisabled(); -}); From 4d5059417c832f3ea3f96e2ff3f3a3b31b64c55c Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 14:21:43 +0000 Subject: [PATCH 36/51] Fix types and imports --- packages/toolpad-components/src/DatePicker.tsx | 2 +- packages/toolpad-components/src/FilePicker.tsx | 2 +- packages/toolpad-components/src/Form.tsx | 2 +- packages/toolpad-components/src/Select.tsx | 2 +- packages/toolpad-components/src/TextField.tsx | 2 +- packages/toolpad-core/src/index.tsx | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 88b541eb60b..34dfcdf4bca 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -9,7 +9,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent, useNode } from '@mui/toolpad-core'; import dayjs from 'dayjs'; import { Controller, FieldError } from 'react-hook-form'; -import { FormContext, useFormInput, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; import { SX_PROP_HELPER_TEXT } from './constants.js'; const LOCALE_LOADERS = new Map([ diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 873c867c022..4a54b1fd712 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { Controller, FieldError } from 'react-hook-form'; -import { FormContext, useFormInput, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; interface FullFile { name: string; diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index c74953693d5..2b44a7c91e7 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -4,7 +4,7 @@ import { LoadingButton } from '@mui/lab'; import { createComponent } from '@mui/toolpad-core'; import { useForm, FieldValues, ValidationMode } from 'react-hook-form'; import * as _ from 'lodash-es'; -import { SX_PROP_HELPER_TEXT } from './constants'; +import { SX_PROP_HELPER_TEXT } from './constants.js'; export const FormContext = React.createContext<{ form: ReturnType | null; diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 4e05c70bb71..6eebcde9161 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError, Controller } from 'react-hook-form'; -import { FormContext, useFormInput, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; import { SX_PROP_HELPER_TEXT } from './constants.js'; export interface SelectOption { diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index 057b81abfa9..a96d46dea88 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -6,7 +6,7 @@ import { } from '@mui/material'; import { createComponent, useNode } from '@mui/toolpad-core'; import { FieldError, Controller } from 'react-hook-form'; -import { FormContext, useFormInput, withComponentForm } from './Form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; import { SX_PROP_HELPER_TEXT } from './constants.js'; export type TextFieldProps = Omit & { diff --git a/packages/toolpad-core/src/index.tsx b/packages/toolpad-core/src/index.tsx index 80419da845b..a0e6afd7aae 100644 --- a/packages/toolpad-core/src/index.tsx +++ b/packages/toolpad-core/src/index.tsx @@ -20,7 +20,7 @@ export * from './types.js'; export * from './componentsContext.js'; -export * from './utils/collections'; +export * from './utils/collections.js'; export { default as createQuery } from './createQuery.js'; export * from './createQuery.js'; From 6e142c308f3ebfba05c742306ea6c6026093a442 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 15:23:08 +0000 Subject: [PATCH 37/51] try to fix tests --- test/integration/editor/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/editor/index.spec.ts b/test/integration/editor/index.spec.ts index 5474215be22..575a4596e8b 100644 --- a/test/integration/editor/index.spec.ts +++ b/test/integration/editor/index.spec.ts @@ -29,7 +29,7 @@ test.describe('from new application', () => { await expect(canvasInputLocator).toHaveCount(1); await expect(canvasInputLocator).toBeVisible(); - expect(await page.getByLabel('name').inputValue()).toBe('textField'); + await expect(page.getByLabel('name')).toHaveValue('textField'); // Drag in a second component From 7af7b6cfc163eba26ad91ec557723c9a841cacd3 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 16:32:45 +0000 Subject: [PATCH 38/51] Allow renaming form fields --- packages/toolpad-components/src/Form.tsx | 10 ++++++++++ test/integration/editor/index.spec.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 2b44a7c91e7..314d2fcd92f 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -187,6 +187,16 @@ export function useFormInput({ } }, [defaultValue, emptyValue, fieldValues, form, isInitialForm, name, onChange, value]); + const previousNodeNameRef = React.useRef(name); + React.useEffect(() => { + const previousNodeName = previousNodeNameRef.current; + + if (form && previousNodeName && previousNodeName !== name) { + form.unregister(previousNodeName); + previousNodeNameRef.current = name; + } + }, [form, name]); + const previousManualValidationPropsRef = React.useRef(validationProps); React.useEffect(() => { if ( diff --git a/test/integration/editor/index.spec.ts b/test/integration/editor/index.spec.ts index 575a4596e8b..6dc2a5875c5 100644 --- a/test/integration/editor/index.spec.ts +++ b/test/integration/editor/index.spec.ts @@ -29,7 +29,7 @@ test.describe('from new application', () => { await expect(canvasInputLocator).toHaveCount(1); await expect(canvasInputLocator).toBeVisible(); - await expect(page.getByLabel('name')).toHaveValue('textField'); + expect(await editorModel.componentEditor.getByLabel('name').inputValue()).toBe('textField'); // Drag in a second component From e56448a71ba412432db50e76480e36ca09a4bbb0 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 17:10:54 +0000 Subject: [PATCH 39/51] Disallow nesting form nodes --- packages/toolpad-app/src/appDom/index.ts | 10 ++++++ .../PageEditor/RenderPanel/RenderOverlay.tsx | 33 ++++++++++++++----- .../src/toolpadComponents/index.tsx | 7 +++- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 9eb412e813a..26c8d469e81 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -16,6 +16,7 @@ import { omit, update, updateOrCreate } from '../utils/immutability'; import { pascalCase, removeDiacritics, uncapitalize } from '../utils/strings'; import { ExactEntriesOf, Maybe } from '../utils/types'; import { mapProperties, mapValues } from '../utils/collections'; +import { getElementNodeComponentId } from '../toolpadComponents'; export const CURRENT_APPDOM_VERSION = 6; @@ -585,6 +586,15 @@ export function getPageAncestor(dom: AppDom, node: AppDomNode): PageNode | null return null; } +/** + * Returns all nodes with a given component type + */ +export function getComponentTypeNodes(dom: AppDom, componentId: string): readonly AppDomNode[] { + return Object.values(dom.nodes).filter( + (node) => isElement(node) && getElementNodeComponentId(node) === componentId, + ); +} + /** * Returns the set of names for which the given node must have a different name */ diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index 133f4977249..e5819610dbe 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx @@ -22,6 +22,8 @@ import { isPageColumn, PAGE_ROW_COMPONENT_ID, PAGE_COLUMN_COMPONENT_ID, + isFormComponent, + FORM_COMPONENT_ID, } from '../../../../toolpadComponents'; import { PinholeOverlay } from '../../../../PinholeOverlay'; import { @@ -518,20 +520,33 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const isEmptyPage = pageNodes.length <= 1; + /** + * Return all nodes that are available for insertion. + * i.e. Exclude all descendants of the current selection since inserting in one of + * them would create a cyclic structure. + */ const availableDropTargets = React.useMemo((): appDom.AppDomNode[] => { if (!draggedNode) { return []; } - /** - * Return all nodes that are available for insertion. - * i.e. Exclude all descendants of the current selection since inserting in one of - * them would create a cyclic structure. - */ - const excludedNodes = - selectedNode && !newNode - ? new Set([selectedNode, ...appDom.getDescendants(dom, selectedNode)]) - : new Set(); + let excludedNodes = new Set(); + + if (selectedNode && !newNode) { + excludedNodes = new Set([ + selectedNode, + ...appDom.getDescendants(dom, selectedNode), + ]); + } + + if (isFormComponent(draggedNode)) { + const formNodes = appDom.getComponentTypeNodes(dom, FORM_COMPONENT_ID); + const formNodeDescendants = formNodes + .map((formNode) => appDom.getDescendants(dom, formNode)) + .flat(); + + formNodeDescendants.forEach(excludedNodes.add, excludedNodes); + } return pageNodes.filter((n) => !excludedNodes.has(n)); }, [dom, draggedNode, newNode, pageNodes, selectedNode]); diff --git a/packages/toolpad-app/src/toolpadComponents/index.tsx b/packages/toolpad-app/src/toolpadComponents/index.tsx index 834feac56ef..6b5329bcc09 100644 --- a/packages/toolpad-app/src/toolpadComponents/index.tsx +++ b/packages/toolpad-app/src/toolpadComponents/index.tsx @@ -17,6 +17,7 @@ export type InstantiatedComponents = Record([ [PAGE_ROW_COMPONENT_ID, { displayName: 'Row', builtIn: 'PageRow', system: true }], @@ -40,7 +41,7 @@ const INTERNAL_COMPONENTS = new Map([ ['Paper', { displayName: 'Paper', builtIn: 'Paper' }], ['Tabs', { displayName: 'Tabs', builtIn: 'Tabs' }], ['Container', { displayName: 'Container', builtIn: 'Container' }], - ['Form', { displayName: 'Form', builtIn: 'Form' }], + [FORM_COMPONENT_ID, { displayName: 'Form', builtIn: 'Form' }], ]); function createCodeComponent(domNode: appDom.CodeComponentNode): ToolpadComponentDefinition { @@ -85,3 +86,7 @@ export function isPageColumn(elementNode: appDom.ElementNode): boolean { export function isPageLayoutComponent(elementNode: appDom.ElementNode): boolean { return isPageRow(elementNode) || isPageColumn(elementNode); } + +export function isFormComponent(elementNode: appDom.ElementNode): boolean { + return getElementNodeComponentId(elementNode) === FORM_COMPONENT_ID; +} From 8802c8b4f01ba1a57e429c264e311b8665a4d501 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 17:40:52 +0000 Subject: [PATCH 40/51] Try to fix CI --- packages/toolpad-app/src/appDom/index.ts | 3 +-- packages/toolpad-app/src/toolpad/AppEditor/NodeNameEditor.tsx | 2 +- test/integration/editor/index.spec.ts | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 26c8d469e81..255abae7eb0 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -16,7 +16,6 @@ import { omit, update, updateOrCreate } from '../utils/immutability'; import { pascalCase, removeDiacritics, uncapitalize } from '../utils/strings'; import { ExactEntriesOf, Maybe } from '../utils/types'; import { mapProperties, mapValues } from '../utils/collections'; -import { getElementNodeComponentId } from '../toolpadComponents'; export const CURRENT_APPDOM_VERSION = 6; @@ -591,7 +590,7 @@ export function getPageAncestor(dom: AppDom, node: AppDomNode): PageNode | null */ export function getComponentTypeNodes(dom: AppDom, componentId: string): readonly AppDomNode[] { return Object.values(dom.nodes).filter( - (node) => isElement(node) && getElementNodeComponentId(node) === componentId, + (node) => isElement(node) && node.attributes.component.value === componentId, ); } diff --git a/packages/toolpad-app/src/toolpad/AppEditor/NodeNameEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/NodeNameEditor.tsx index 9458efe654c..cc6e2826b98 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/NodeNameEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/NodeNameEditor.tsx @@ -46,7 +46,7 @@ export default function NodeNameEditor({ node, sx }: NodeNameEditorProps) { { await expect(canvasInputLocator).toHaveCount(1); await expect(canvasInputLocator).toBeVisible(); - expect(await editorModel.componentEditor.getByLabel('name').inputValue()).toBe('textField'); + await expect(editorModel.componentEditor.getByLabel('Node name', { exact: true })).toHaveValue( + 'textField', + ); // Drag in a second component From ee1ba95a67374598a6f9c63245d915dc14cb030a Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 24 Mar 2023 17:48:49 +0000 Subject: [PATCH 41/51] Why didn't Prettier run --- packages/toolpad-app/src/toolpadComponents/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/toolpad-app/src/toolpadComponents/index.tsx b/packages/toolpad-app/src/toolpadComponents/index.tsx index 6b5329bcc09..cce630fc117 100644 --- a/packages/toolpad-app/src/toolpadComponents/index.tsx +++ b/packages/toolpad-app/src/toolpadComponents/index.tsx @@ -17,7 +17,7 @@ export type InstantiatedComponents = Record([ [PAGE_ROW_COMPONENT_ID, { displayName: 'Row', builtIn: 'PageRow', system: true }], From e4b4c84f69f935e1170d1e085495d918a175b9a2 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Tue, 28 Mar 2023 18:59:57 +0100 Subject: [PATCH 42/51] Replace all page root waits with waitForOverlay --- test/integration/data-grid/index.spec.ts | 4 ++-- test/integration/editor/index.spec.ts | 6 +++--- test/integration/file-picker/index.spec.ts | 2 +- test/integration/pages/index.spec.ts | 2 +- test/integration/propControls/index.spec.ts | 4 ++-- test/integration/undo-redo/index.spec.ts | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/integration/data-grid/index.spec.ts b/test/integration/data-grid/index.spec.ts index f7f62fb5210..4713858dfcb 100644 --- a/test/integration/data-grid/index.spec.ts +++ b/test/integration/data-grid/index.spec.ts @@ -15,7 +15,7 @@ test.describe('custom component columns', () => { const editorModel = new ToolpadEditor(page); editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); await expect(editorModel.pageRoot.getByText('value: {"test":"value"}')).toBeVisible(); await expect( @@ -39,7 +39,7 @@ test.describe('basic tests', () => { const editorModel = new ToolpadEditor(page); editorModel.goto(); - await editorModel.pageRoot.waitFor({ state: 'visible' }); + await editorModel.waitForOverlay(); const canvasGridLocator = editorModel.appCanvas.getByRole('grid'); diff --git a/test/integration/editor/index.spec.ts b/test/integration/editor/index.spec.ts index a4c24d01009..fa22711f498 100644 --- a/test/integration/editor/index.spec.ts +++ b/test/integration/editor/index.spec.ts @@ -15,7 +15,7 @@ test.describe('from new application', () => { await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -63,7 +63,7 @@ test.describe('with fixture', () => { await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const canvasInputLocator = editorModel.appCanvas.locator('input'); const canvasMoveElementHandleLocator = editorModel.appCanvas.getByTestId('node-hud-tag'); @@ -107,7 +107,7 @@ test.describe('with fixture', () => { await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const canvasInputLocator = editorModel.appCanvas.locator('input'); diff --git a/test/integration/file-picker/index.spec.ts b/test/integration/file-picker/index.spec.ts index 87f7cb2c700..27556bdfb9a 100644 --- a/test/integration/file-picker/index.spec.ts +++ b/test/integration/file-picker/index.spec.ts @@ -15,7 +15,7 @@ test('File picker component', async ({ page }) => { const editorModel = new ToolpadEditor(page); editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const filePicker = editorModel.pageRoot.locator('label'); diff --git a/test/integration/pages/index.spec.ts b/test/integration/pages/index.spec.ts index 5c4d313efdc..88e310ef5d8 100644 --- a/test/integration/pages/index.spec.ts +++ b/test/integration/pages/index.spec.ts @@ -14,7 +14,7 @@ test('must load page in initial URL without altering URL', async ({ page }) => { await page.goto(`/_toolpad/app/pages/g433ywb?abcd=123`); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const pageButton2 = editorModel.appCanvas.getByRole('button', { name: 'page2Button', diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts index 99ca3c8b4a0..8efee2844a7 100644 --- a/test/integration/propControls/index.spec.ts +++ b/test/integration/propControls/index.spec.ts @@ -16,7 +16,7 @@ test.describe('basic', () => { await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -68,7 +68,7 @@ test.describe('default values', () => { const editorModel = new ToolpadEditor(page); await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const firstInput = editorModel.appCanvas.locator('input').nth(0); const secondInput = editorModel.appCanvas.locator('input').nth(1); diff --git a/test/integration/undo-redo/index.spec.ts b/test/integration/undo-redo/index.spec.ts index 899f0431eaa..36fc7805176 100644 --- a/test/integration/undo-redo/index.spec.ts +++ b/test/integration/undo-redo/index.spec.ts @@ -15,7 +15,7 @@ test.describe('basic tests', () => { const editorModel = new ToolpadEditor(page); await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -44,7 +44,7 @@ test.describe('basic tests', () => { const editorModel = new ToolpadEditor(page); await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const input = editorModel.appCanvas.locator('input').first(); @@ -86,7 +86,7 @@ test.describe('multiple pages', () => { const editorModel = new ToolpadEditor(page); await editorModel.goto(); - await editorModel.pageRoot.waitFor(); + await editorModel.waitForOverlay(); const pageButton1 = editorModel.appCanvas.getByRole('button', { name: 'page1Button', From 7ff5266484fdc11eb4c75ea5fb162deb65c01f0f Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Thu, 6 Apr 2023 16:27:11 +0100 Subject: [PATCH 43/51] Try to fix last merge --- test/integration/data-grid/basic.spec.ts | 2 +- test/integration/data-grid/index.spec.ts | 79 ----------------- test/integration/propControls/basic.spec.ts | 4 +- test/integration/propControls/index.spec.ts | 95 --------------------- 4 files changed, 2 insertions(+), 178 deletions(-) delete mode 100644 test/integration/data-grid/index.spec.ts delete mode 100644 test/integration/propControls/index.spec.ts diff --git a/test/integration/data-grid/basic.spec.ts b/test/integration/data-grid/basic.spec.ts index 44c0f9bb13a..d541e78ddc6 100644 --- a/test/integration/data-grid/basic.spec.ts +++ b/test/integration/data-grid/basic.spec.ts @@ -14,7 +14,7 @@ test('Column prop updates are not lost on drag interactions', async ({ page }) = const editorModel = new ToolpadEditor(page); editorModel.goto(); - await editorModel.pageRoot.waitFor({ state: 'visible' }); + await editorModel.waitForOverlay(); const canvasGridLocator = editorModel.appCanvas.getByRole('grid'); diff --git a/test/integration/data-grid/index.spec.ts b/test/integration/data-grid/index.spec.ts deleted file mode 100644 index 4713858dfcb..00000000000 --- a/test/integration/data-grid/index.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as path from 'path'; -import { ToolpadEditor } from '../../models/ToolpadEditor'; -import { test, expect } from '../../playwright/localTest'; -import clickCenter from '../../utils/clickCenter'; - -test.describe('custom component columns', () => { - test.use({ - localAppConfig: { - template: path.resolve(__dirname, './fixture-custom'), - cmd: 'dev', - }, - }); - - test('Code component cell', async ({ page }) => { - const editorModel = new ToolpadEditor(page); - editorModel.goto(); - - await editorModel.waitForOverlay(); - - await expect(editorModel.pageRoot.getByText('value: {"test":"value"}')).toBeVisible(); - await expect( - editorModel.pageRoot.getByText( - 'row: {"hiddenField":true,"customField":{"test":"value"},"id":0}', - ), - ).toBeVisible(); - await expect(editorModel.pageRoot.getByText('field: "customField"')).toBeVisible(); - }); -}); - -test.describe('basic tests', () => { - test.use({ - localAppConfig: { - template: path.resolve(__dirname, './fixture-basic'), - cmd: 'dev', - }, - }); - - test('Column prop updates are not lost on drag interactions', async ({ page }) => { - const editorModel = new ToolpadEditor(page); - editorModel.goto(); - - await editorModel.waitForOverlay(); - - const canvasGridLocator = editorModel.appCanvas.getByRole('grid'); - - // Change the "Avatar" column type from "link" to "boolean" - - const firstGridLocator = canvasGridLocator.first(); - - await clickCenter(page, firstGridLocator); - - await editorModel.componentEditor.locator('button:has-text("columns")').click(); - - await editorModel.page.getByRole('button', { name: 'Avatar' }).click(); - - await editorModel.page.getByRole('button', { name: 'link' }).click(); - - await editorModel.page.getByRole('option', { name: 'boolean' }).click(); - - await page.keyboard.press('Escape'); - - // Drag the "Avatar" column to the end of the grid - - const avatarColumn = editorModel.pageRoot.getByText('Avatar', { exact: true }); - const profileColumn = editorModel.pageRoot.getByText('Profile', { exact: true }); - - await avatarColumn.dragTo(profileColumn); - - // Expect the "Avatar" column to continue to be of type "boolean" instead of "link" - - await expect( - editorModel.pageRoot - .getByRole('row', { - name: '1 Todd Breitenberg International http://spotless-octopus.name', - }) - .getByTestId('CheckIcon'), - ).toBeVisible(); - }); -}); diff --git a/test/integration/propControls/basic.spec.ts b/test/integration/propControls/basic.spec.ts index 4faa74e6f33..4d93befcaa9 100644 --- a/test/integration/propControls/basic.spec.ts +++ b/test/integration/propControls/basic.spec.ts @@ -15,8 +15,6 @@ test('can control component prop values in properties control panel', async ({ p await editorModel.goto(); - await editorModel.pageRoot.waitFor(); - await editorModel.waitForOverlay(); const canvasInputLocator = editorModel.appCanvas.locator('input'); @@ -41,7 +39,7 @@ test('can control component prop values in properties control panel', async ({ p const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); expect(await valueControl.inputValue()).not.toBe(TEST_VALUE_1); await firstInputLocator.fill(TEST_VALUE_1); - expect(await valueControl.inputValue()).toBe(TEST_VALUE_1); + await expect(valueControl).toHaveValue(TEST_VALUE_1); await expect(valueControl).toBeDisabled(); diff --git a/test/integration/propControls/index.spec.ts b/test/integration/propControls/index.spec.ts deleted file mode 100644 index 8efee2844a7..00000000000 --- a/test/integration/propControls/index.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import * as path from 'path'; -import { test, expect } from '../../playwright/localTest'; -import { ToolpadEditor } from '../../models/ToolpadEditor'; -import clickCenter from '../../utils/clickCenter'; - -test.describe('basic', () => { - test.use({ - localAppConfig: { - template: path.resolve(__dirname, './fixture-basic'), - cmd: 'dev', - }, - }); - - test('can control component prop values in properties control panel', async ({ page }) => { - const editorModel = new ToolpadEditor(page); - - await editorModel.goto(); - - await editorModel.waitForOverlay(); - - const canvasInputLocator = editorModel.appCanvas.locator('input'); - - // Verify that initial prop control values are correct - - const firstInputLocator = canvasInputLocator.first(); - await clickCenter(page, firstInputLocator); - - await editorModel.componentEditor - .locator('h6:has-text("Text field")') - .waitFor({ state: 'visible' }); - - const labelControlInput = editorModel.componentEditor.getByLabel('label', { exact: true }); - - const labelControlInputValue = await labelControlInput.inputValue(); - - expect(labelControlInputValue).toBe('textField1'); - - // Change component prop values directly - const TEST_VALUE_1 = 'value1'; - const valueControl = editorModel.componentEditor.getByLabel('value', { exact: true }); - expect(await valueControl.inputValue()).not.toBe(TEST_VALUE_1); - await firstInputLocator.fill(TEST_VALUE_1); - await expect(valueControl).toHaveValue(TEST_VALUE_1); - - await expect(valueControl).toBeDisabled(); - - // Change component prop values through controls - const TEST_VALUE_2 = 'value2'; - const inputByLabel = editorModel.appCanvas.getByLabel(TEST_VALUE_2, { exact: true }); - await expect(inputByLabel).toHaveCount(0); - await labelControlInput.click(); - await labelControlInput.fill(''); - await labelControlInput.fill(TEST_VALUE_2); - - await inputByLabel.waitFor({ state: 'visible' }); - }); -}); - -test.describe('default values', () => { - test.use({ - localAppConfig: { - template: path.resolve(__dirname, './fixture-defaults'), - cmd: 'dev', - }, - }); - - test('changing defaultValue resets controlled value', async ({ page }) => { - const editorModel = new ToolpadEditor(page); - await editorModel.goto(); - - await editorModel.waitForOverlay(); - - const firstInput = editorModel.appCanvas.locator('input').nth(0); - const secondInput = editorModel.appCanvas.locator('input').nth(1); - - await secondInput.focus(); - - await page.keyboard.type('Extra'); - - await expect(firstInput).toHaveValue('defaultTwoExtra'); - - await firstInput.focus(); - - await page.keyboard.type('Value'); - - await expect(firstInput).toHaveValue('defaultTwoExtraValue'); - - clickCenter(page, secondInput); - - await editorModel.componentEditor.getByLabel('defaultValue', { exact: true }).fill('New'); - - await expect(firstInput).toHaveValue('New'); - await expect(secondInput).toHaveValue('New'); - }); -}); From 4d2a6d3d70ea17014ec1b9396df05754fd99f6ff Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Mon, 10 Apr 2023 18:54:37 +0100 Subject: [PATCH 44/51] Fix tests --- packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx | 4 +++- packages/toolpad-components/src/DatePicker.tsx | 3 ++- packages/toolpad-components/src/FilePicker.tsx | 3 ++- packages/toolpad-components/src/Form.tsx | 11 +++++------ packages/toolpad-components/src/Select.tsx | 3 ++- packages/toolpad-components/src/TextField.tsx | 3 ++- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx index f4459c39927..749375a78cd 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx @@ -103,7 +103,9 @@ test(`simple databinding`, async () => { fireEvent.change(textField, { target: { value: 'Hello Everybody' } }); }); - expect(text).toHaveTextContent('Hello Everybody'); + await waitFor(() => { + expect(text).toHaveTextContent('Hello Everybody'); + }); }); test(`default Value for binding`, async () => { diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 88225ec53f2..06ac9cd32de 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -98,7 +98,8 @@ function DatePicker({ }: DatePickerProps) { const nodeRuntime = useNode(); - const nodeName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); + const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index 4a54b1fd712..b1c37beee9b 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -47,7 +47,8 @@ function FilePicker({ }: FilePickerProps) { const nodeRuntime = useNode(); - const nodeName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); + const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; diff --git a/packages/toolpad-components/src/Form.tsx b/packages/toolpad-components/src/Form.tsx index 314d2fcd92f..4530ed03a7d 100644 --- a/packages/toolpad-components/src/Form.tsx +++ b/packages/toolpad-components/src/Form.tsx @@ -129,7 +129,7 @@ function Form({ } interface UseFormInputInput { - name?: string | null; + name: string; value?: V; onChange: (newValue: V) => void; emptyValue?: V; @@ -153,7 +153,7 @@ export function useFormInput({ const handleFormInputChange = React.useCallback( (newValue: V) => { - if (form && name) { + if (form) { form.setValue(name, newValue, { shouldValidate: true, shouldDirty: true, @@ -167,7 +167,7 @@ export function useFormInput({ const previousDefaultValueRef = React.useRef(defaultValue); React.useEffect(() => { - if (form && name && defaultValue !== previousDefaultValueRef.current) { + if (form && defaultValue !== previousDefaultValueRef.current) { onChange(defaultValue as V); form.setValue(name, defaultValue); previousDefaultValueRef.current = defaultValue; @@ -177,7 +177,7 @@ export function useFormInput({ const isInitialForm = Object.keys(fieldValues).length === 0; React.useEffect(() => { - if (form && name) { + if (form) { if (!fieldValues[name] && defaultValue && isInitialForm) { onChange((defaultValue || emptyValue) as V); form.setValue(name, defaultValue || emptyValue); @@ -191,7 +191,7 @@ export function useFormInput({ React.useEffect(() => { const previousNodeName = previousNodeNameRef.current; - if (form && previousNodeName && previousNodeName !== name) { + if (form && previousNodeName !== name) { form.unregister(previousNodeName); previousNodeNameRef.current = name; } @@ -201,7 +201,6 @@ export function useFormInput({ React.useEffect(() => { if ( form && - name && !_.isEqual(validationProps, previousManualValidationPropsRef.current) && form.formState.dirtyFields[name] ) { diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index 6eebcde9161..e58da16e204 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -33,7 +33,8 @@ function Select({ }: SelectProps) { const nodeRuntime = useNode(); - const nodeName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); + const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; diff --git a/packages/toolpad-components/src/TextField.tsx b/packages/toolpad-components/src/TextField.tsx index a96d46dea88..60be9c74e7f 100644 --- a/packages/toolpad-components/src/TextField.tsx +++ b/packages/toolpad-components/src/TextField.tsx @@ -34,7 +34,8 @@ function TextField({ }: TextFieldProps) { const nodeRuntime = useNode(); - const nodeName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); + const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; From 3f0559d7e09e7f387c63be1ae64188397908a618 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Mon, 10 Apr 2023 19:13:47 +0100 Subject: [PATCH 45/51] Fix tests more --- test/integration/editor/new.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/editor/new.spec.ts b/test/integration/editor/new.spec.ts index 3cc1d2aa872..73847854b33 100644 --- a/test/integration/editor/new.spec.ts +++ b/test/integration/editor/new.spec.ts @@ -26,7 +26,7 @@ test('can place new components from catalog', async ({ page }) => { await expect(canvasInputLocator).toHaveCount(1); await expect(canvasInputLocator).toBeVisible(); - expect(await page.getByLabel('name').inputValue()).toBe('textField'); + expect(await page.getByLabel('Node name').inputValue()).toBe('textField'); // Drag in a second component From ba56e7a2c7d29468708e35ceaf63305d194612bf Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Tue, 11 Apr 2023 22:47:04 +0100 Subject: [PATCH 46/51] Pass props directly + fallback field display name --- .../toolpad-components/src/DatePicker.tsx | 54 +++++++++---------- .../toolpad-components/src/FilePicker.tsx | 38 ++++++------- packages/toolpad-components/src/Select.tsx | 38 +++++++------ packages/toolpad-components/src/TextField.tsx | 28 +++++----- 4 files changed, 82 insertions(+), 76 deletions(-) diff --git a/packages/toolpad-components/src/DatePicker.tsx b/packages/toolpad-components/src/DatePicker.tsx index 06ac9cd32de..3c2e31ee7c3 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -1,10 +1,6 @@ import * as React from 'react'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { - DesktopDatePicker, - DesktopDatePickerProps, - DesktopDatePickerSlotsComponentsProps, -} from '@mui/x-date-pickers/DesktopDatePicker'; +import { DesktopDatePicker, DesktopDatePickerProps } from '@mui/x-date-pickers/DesktopDatePicker'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { createComponent, useNode } from '@mui/toolpad-core'; import dayjs from 'dayjs'; @@ -98,8 +94,10 @@ function DatePicker({ }: DatePickerProps) { const nodeRuntime = useNode(); + const fieldName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); - const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; + const nodeName = fieldName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; @@ -144,29 +142,29 @@ function DatePicker({ [defaultValueProp], ); - const datePickerProps: DesktopDatePickerProps = { - ...rest, - format: format || 'L', - value: value || null, - onChange: handleChange, - defaultValue, - slotProps: { - textField: { - fullWidth: rest.fullWidth, - variant: rest.variant, - size: rest.size, - sx: rest.sx, - ...(form && { - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - }), - }, - } as DesktopDatePickerSlotsComponentsProps, - }; - - const datePickerElement = {...datePickerProps} />; + const datePickerElement = ( + + {...rest} + format={format || 'L'} + value={value || null} + onChange={handleChange} + defaultValue={defaultValue} + slotProps={{ + textField: { + fullWidth: rest.fullWidth, + variant: rest.variant, + size: rest.size, + sx: rest.sx, + ...(form && { + error: Boolean(fieldError), + helperText: (fieldError as FieldError)?.message || '', + }), + }, + }} + /> + ); - const fieldDisplayName = rest.label || nodeName; + const fieldDisplayName = rest.label || fieldName || 'Field'; return ( diff --git a/packages/toolpad-components/src/FilePicker.tsx b/packages/toolpad-components/src/FilePicker.tsx index b1c37beee9b..6c79158dcb2 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -47,8 +47,10 @@ function FilePicker({ }: FilePickerProps) { const nodeRuntime = useNode(); + const fieldName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); - const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; + const nodeName = fieldName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; @@ -83,22 +85,22 @@ function FilePicker({ } }; - const filePickerProps = { - ...rest, - type: 'file', - value: undefined, - onChange: handleChange, - inputProps: { - multiple, - }, - InputLabelProps: { shrink: true }, - ...(form && { - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - }), - }; + const filePickerElement = ( + + ); - const fieldDisplayName = rest.label || nodeName; + const fieldDisplayName = rest.label || fieldName || 'Field'; return form && nodeName ? ( !isInvalid || `${fieldDisplayName} is invalid.`, }} - render={() => } + render={() => filePickerElement} /> ) : ( - + filePickerElement ); } diff --git a/packages/toolpad-components/src/Select.tsx b/packages/toolpad-components/src/Select.tsx index e58da16e204..69d1f3a6d56 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -33,8 +33,10 @@ function Select({ }: SelectProps) { const nodeRuntime = useNode(); + const fieldName = rest.name || nodeRuntime?.nodeName; + const fallbackName = React.useId(); - const nodeName = rest.name || nodeRuntime?.nodeName || fallbackName; + const nodeName = fieldName || fallbackName; const { form } = React.useContext(FormContext); const fieldError = nodeName && form?.formState.errors[nodeName]; @@ -78,22 +80,24 @@ function Select({ [id, options], ); - const selectProps = { - ...rest, - value, - onChange: handleChange, - select: true, - sx: { ...(!fullWidth && !value ? { width: 120 } : {}), ...sx }, - fullWidth: true, - ...(form && { - error: Boolean(fieldError), - helperText: (fieldError as FieldError)?.message || '', - }), - }; - - const selectElement = {renderedOptions}; - - const fieldDisplayName = rest.label || nodeName; + const selectElement = ( + + {renderedOptions} + + ); + + const fieldDisplayName = rest.label || fieldName || 'Field'; return form && nodeName ? ( ; + const textFieldElement = ( + + ); - const fieldDisplayName = rest.label || nodeName; + const fieldDisplayName = rest.label || fieldName || 'Field'; return form && nodeName ? ( Date: Fri, 14 Apr 2023 17:44:10 +0100 Subject: [PATCH 47/51] Fix container hover border --- .../AppEditor/PageEditor/RenderPanel/NodeHud.tsx | 13 +------------ .../PageEditor/RenderPanel/RenderOverlay.tsx | 15 +-------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx index ad3ef3c0643..65d3e7a9d34 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import clsx from 'clsx'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DeleteIcon from '@mui/icons-material/Delete'; import ContentCopy from '@mui/icons-material/ContentCopy'; @@ -26,7 +25,6 @@ function stopPropagationHandler(event: React.SyntheticEvent) { } const nodeHudClasses = { - allowNodeInteraction: 'NodeHud_AllowNodeInteraction', selected: 'NodeHud_Selected', selectionHint: 'NodeHud_SelectionHint', }; @@ -44,7 +42,7 @@ const NodeHudWrapper = styled('div', { outline: `1px dotted ${isOutlineVisible ? theme.palette.primary[500] : 'transparent'}`, zIndex: 80, '&:hover': { - outline: `2px dashed ${isHoverable ? 'transparent' : theme.palette.primary[500]}`, + outline: `2px dashed ${isHoverable ? theme.palette.primary[500] : 'transparent'}`, }, [`.${nodeHudClasses.selected}`]: { position: 'absolute', @@ -55,10 +53,6 @@ const NodeHudWrapper = styled('div', { top: 0, zIndex: 80, }, - [`&.${nodeHudClasses.allowNodeInteraction}`]: { - // block pointer-events so we can interact with the selection - pointerEvents: 'none', - }, })); const SelectionHintWrapper = styled('div', { @@ -145,7 +139,6 @@ interface NodeHudProps { node: appDom.AppDomNode; rect: Rectangle; isSelected?: boolean; - isInteractive?: boolean; onNodeDragStart?: React.DragEventHandler; draggableEdges?: RectangleEdge[]; onEdgeDragStart?: (edge: RectangleEdge) => React.MouseEventHandler; @@ -161,7 +154,6 @@ export default function NodeHud({ node, rect, isSelected, - isInteractive, onNodeDragStart, draggableEdges = [], onEdgeDragStart, @@ -179,9 +171,6 @@ export default function NodeHud({ diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index 350d929cb2b..993cf08a73d 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx @@ -433,17 +433,6 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const selectedRect = selectedNode && !newNode ? nodesInfo[selectedNode.id]?.rect : null; - const interactiveNodes = React.useMemo>(() => { - if (!selectedNode) { - return new Set(); - } - return new Set( - [...appDom.getPageAncestors(dom, selectedNode), selectedNode].map( - (interactiveNode) => interactiveNode.id, - ), - ); - }, [dom, selectedNode]); - const handleNodeDragStart = React.useCallback( (node: appDom.ElementNode) => (event: React.DragEvent) => { event.stopPropagation(); @@ -1596,7 +1585,6 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const isPageColumnChild = parent ? appDom.isElement(parent) && isPageColumn(parent) : false; const isSelected = selectedNode && !newNode ? selectedNode.id === node.id : false; - const isInteractive = interactiveNodes.has(node.id) && !draggedNode && !draggedEdge; const isHorizontallyResizable = isSelected && (isPageRowChild || isPageColumnChild); const isVerticallyResizable = @@ -1616,7 +1604,6 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { node={node} rect={nodeRect} isSelected={isSelected} - isInteractive={isInteractive} onNodeDragStart={handleNodeDragStart(node as appDom.ElementNode)} onDuplicate={handleNodeDuplicate(node as appDom.ElementNode)} draggableEdges={[ @@ -1633,7 +1620,7 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { onDelete={handleNodeDelete(node.id)} isResizing={isResizingNode} resizePreviewElementRef={resizePreviewElementRef} - isHoverable={isResizing && !isDraggingOver} + isHoverable={!isResizing && !isDraggingOver} isOutlineVisible={isDraggingOver} /> ) : null} From 699e6cd3536614b7bc40000d1e4d873319a0293b Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 14 Apr 2023 17:50:13 +0100 Subject: [PATCH 48/51] Try test without waitFor --- packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx index c4a0fc15a51..bff6621960f 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.spec.tsx @@ -109,9 +109,7 @@ test(`simple databinding`, async () => { fireEvent.change(textField, { target: { value: 'Hello Everybody' } }); }); - await waitFor(() => { - expect(text).toHaveTextContent('Hello Everybody'); - }); + expect(text).toHaveTextContent('Hello Everybody'); }); test(`default Value for binding`, async () => { From dd912ebb6fe66e05ee5dd6d3146e44e44d87b194 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 14 Apr 2023 18:27:39 +0100 Subject: [PATCH 49/51] Test fix attempt --- .../AppEditor/PageEditor/RenderPanel/NodeHud.tsx | 11 +++++++++++ .../PageEditor/RenderPanel/RenderOverlay.tsx | 2 ++ 2 files changed, 13 insertions(+) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx index 65d3e7a9d34..6de1e3b322f 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import clsx from 'clsx'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DeleteIcon from '@mui/icons-material/Delete'; import ContentCopy from '@mui/icons-material/ContentCopy'; @@ -25,6 +26,7 @@ function stopPropagationHandler(event: React.SyntheticEvent) { } const nodeHudClasses = { + allowNodeInteraction: 'NodeHud_AllowNodeInteraction', selected: 'NodeHud_Selected', selectionHint: 'NodeHud_SelectionHint', }; @@ -53,6 +55,10 @@ const NodeHudWrapper = styled('div', { top: 0, zIndex: 80, }, + [`&.${nodeHudClasses.allowNodeInteraction}`]: { + // block pointer-events so we can interact with the selection + pointerEvents: 'none', + }, })); const SelectionHintWrapper = styled('div', { @@ -139,6 +145,7 @@ interface NodeHudProps { node: appDom.AppDomNode; rect: Rectangle; isSelected?: boolean; + isInteractive?: boolean; onNodeDragStart?: React.DragEventHandler; draggableEdges?: RectangleEdge[]; onEdgeDragStart?: (edge: RectangleEdge) => React.MouseEventHandler; @@ -154,6 +161,7 @@ export default function NodeHud({ node, rect, isSelected, + isInteractive, onNodeDragStart, draggableEdges = [], onEdgeDragStart, @@ -171,6 +179,9 @@ export default function NodeHud({ diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index 993cf08a73d..798f6aafd58 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx @@ -1585,6 +1585,7 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const isPageColumnChild = parent ? appDom.isElement(parent) && isPageColumn(parent) : false; const isSelected = selectedNode && !newNode ? selectedNode.id === node.id : false; + const isInteractive = !draggedNode && !draggedEdge; const isHorizontallyResizable = isSelected && (isPageRowChild || isPageColumnChild); const isVerticallyResizable = @@ -1604,6 +1605,7 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { node={node} rect={nodeRect} isSelected={isSelected} + isInteractive={isInteractive} onNodeDragStart={handleNodeDragStart(node as appDom.ElementNode)} onDuplicate={handleNodeDuplicate(node as appDom.ElementNode)} draggableEdges={[ From f06e5f4b3e8aca5d3b51e6ead69c95844b4f153d Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 14 Apr 2023 18:32:48 +0100 Subject: [PATCH 50/51] Fix isInteractive prop --- .../toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx index 6de1e3b322f..9503c286307 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx @@ -26,7 +26,7 @@ function stopPropagationHandler(event: React.SyntheticEvent) { } const nodeHudClasses = { - allowNodeInteraction: 'NodeHud_AllowNodeInteraction', + disableNodeInteraction: 'NodeHud_DisableNodeInteraction', selected: 'NodeHud_Selected', selectionHint: 'NodeHud_SelectionHint', }; @@ -55,7 +55,7 @@ const NodeHudWrapper = styled('div', { top: 0, zIndex: 80, }, - [`&.${nodeHudClasses.allowNodeInteraction}`]: { + [`&.${nodeHudClasses.disableNodeInteraction}`]: { // block pointer-events so we can interact with the selection pointerEvents: 'none', }, @@ -180,7 +180,7 @@ export default function NodeHud({ data-node-id={node.id} style={absolutePositionCss(rect)} className={clsx({ - [nodeHudClasses.allowNodeInteraction]: isInteractive, + [nodeHudClasses.disableNodeInteraction]: !isInteractive, })} isOutlineVisible={isOutlineVisible} isHoverable={isHoverable} From cdc220f6e9b387953226ff082e9af418157798b9 Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Fri, 14 Apr 2023 19:50:50 +0100 Subject: [PATCH 51/51] Fix interactive node issues --- .../PageEditor/RenderPanel/NodeHud.tsx | 6 +++--- .../PageEditor/RenderPanel/RenderOverlay.tsx | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx index 9503c286307..6de1e3b322f 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/NodeHud.tsx @@ -26,7 +26,7 @@ function stopPropagationHandler(event: React.SyntheticEvent) { } const nodeHudClasses = { - disableNodeInteraction: 'NodeHud_DisableNodeInteraction', + allowNodeInteraction: 'NodeHud_AllowNodeInteraction', selected: 'NodeHud_Selected', selectionHint: 'NodeHud_SelectionHint', }; @@ -55,7 +55,7 @@ const NodeHudWrapper = styled('div', { top: 0, zIndex: 80, }, - [`&.${nodeHudClasses.disableNodeInteraction}`]: { + [`&.${nodeHudClasses.allowNodeInteraction}`]: { // block pointer-events so we can interact with the selection pointerEvents: 'none', }, @@ -180,7 +180,7 @@ export default function NodeHud({ data-node-id={node.id} style={absolutePositionCss(rect)} className={clsx({ - [nodeHudClasses.disableNodeInteraction]: !isInteractive, + [nodeHudClasses.allowNodeInteraction]: isInteractive, })} isOutlineVisible={isOutlineVisible} isHoverable={isHoverable} diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index 798f6aafd58..514eccd4e08 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx @@ -433,6 +433,20 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const selectedRect = selectedNode && !newNode ? nodesInfo[selectedNode.id]?.rect : null; + const interactiveNodes = React.useMemo>(() => { + if (!selectedNode) { + return new Set(); + } + return new Set( + [ + ...appDom + .getPageAncestors(dom, selectedNode) + .filter((ancestor) => appDom.isElement(ancestor) && isPageLayoutComponent(ancestor)), + selectedNode, + ].map((ancestor) => ancestor.id), + ); + }, [dom, selectedNode]); + const handleNodeDragStart = React.useCallback( (node: appDom.ElementNode) => (event: React.DragEvent) => { event.stopPropagation(); @@ -1585,7 +1599,6 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const isPageColumnChild = parent ? appDom.isElement(parent) && isPageColumn(parent) : false; const isSelected = selectedNode && !newNode ? selectedNode.id === node.id : false; - const isInteractive = !draggedNode && !draggedEdge; const isHorizontallyResizable = isSelected && (isPageRowChild || isPageColumnChild); const isVerticallyResizable = @@ -1594,6 +1607,8 @@ export default function RenderOverlay({ bridge }: RenderOverlayProps) { const isResizing = Boolean(draggedEdge); const isResizingNode = isResizing && node.id === draggedNodeId; + const isInteractive = interactiveNodes.has(node.id) && !isResizing && !isDraggingOver; + if (!nodeRect) { return null; }