From 854056d3389df7b5fd52babfd74bab4eedb3d2ba Mon Sep 17 00:00:00 2001 From: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com> Date: Mon, 24 Apr 2023 18:49:23 +0100 Subject: [PATCH] Form component (#1926) Signed-off-by: Jan Potoms <2109932+Janpot@users.noreply.github.com> Co-authored-by: bytasv Co-authored-by: Jan Potoms <2109932+Janpot@users.noreply.github.com> --- packages/toolpad-app/src/appDom/index.ts | 9 + .../toolpad-app/src/runtime/ToolpadApp.tsx | 2 +- .../src/toolpad/AppEditor/NodeNameEditor.tsx | 2 +- .../ComponentCatalog/ComponentCatalog.tsx | 1 - .../AppEditor/PageEditor/ComponentEditor.tsx | 44 +-- .../AppEditor/PageEditor/EditorCanvasHost.tsx | 2 +- .../PageEditor/RenderPanel/RenderOverlay.tsx | 33 +- .../src/toolpadComponents/index.tsx | 6 + packages/toolpad-components/src/Button.tsx | 4 + .../toolpad-components/src/DatePicker.tsx | 117 +++++-- .../toolpad-components/src/FilePicker.tsx | 88 +++++- packages/toolpad-components/src/Form.tsx | 287 ++++++++++++++++++ packages/toolpad-components/src/Paper.tsx | 4 +- packages/toolpad-components/src/Select.tsx | 118 +++++-- packages/toolpad-components/src/Tabs.tsx | 4 +- packages/toolpad-components/src/TextField.tsx | 123 +++++++- packages/toolpad-components/src/index.tsx | 2 + packages/toolpad-core/src/runtime.tsx | 26 +- packages/toolpad-core/src/types.ts | 5 +- .../.generated/functions/toolpad_main.js | 104 +++++++ .../fixture-form/toolpad/.gitignore | 1 + .../fixture-form/toolpad/pages/form/page.yml | 78 +++++ .../toolpad/resources/functions.ts | 9 + test/integration/components/form.spec.ts | 97 ++++++ test/integration/components/test.txt | 1 + test/integration/data-grid/basic.spec.ts | 2 +- test/integration/editor/new.spec.ts | 2 +- test/integration/pages/index.spec.ts | 2 +- test/integration/propControls/basic.spec.ts | 4 +- 29 files changed, 1075 insertions(+), 102 deletions(-) create mode 100644 packages/toolpad-components/src/Form.tsx create mode 100644 test/integration/bindings/fixture-navigation/toolpad/.generated/functions/toolpad_main.js create mode 100644 test/integration/components/fixture-form/toolpad/.gitignore create mode 100644 test/integration/components/fixture-form/toolpad/pages/form/page.yml create mode 100644 test/integration/components/fixture-form/toolpad/resources/functions.ts create mode 100644 test/integration/components/form.spec.ts create mode 100644 test/integration/components/test.txt diff --git a/packages/toolpad-app/src/appDom/index.ts b/packages/toolpad-app/src/appDom/index.ts index 959e31df7cf..a8649f527f3 100644 --- a/packages/toolpad-app/src/appDom/index.ts +++ b/packages/toolpad-app/src/appDom/index.ts @@ -607,6 +607,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) && node.attributes.component.value === componentId, + ); +} + /** * Returns the set of names for which the given node must have a different name */ diff --git a/packages/toolpad-app/src/runtime/ToolpadApp.tsx b/packages/toolpad-app/src/runtime/ToolpadApp.tsx index 1add392bcf1..026d07236d7 100644 --- a/packages/toolpad-app/src/runtime/ToolpadApp.tsx +++ b/packages/toolpad-app/src/runtime/ToolpadApp.tsx @@ -462,6 +462,7 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC return ( @@ -1085,7 +1086,6 @@ function RenderedPage({ nodeId }: RenderedNodeProps) { childNodeGroups={{ children }} Component={PageRootComponent} /> - {queries.map((node) => ( ))} 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) { ([ ['Drawer', { url: 'https://github.com/mui/mui-toolpad/issues/1540', displayName: 'Drawer' }], ['Html', { url: 'https://github.com/mui/mui-toolpad/issues/1311', displayName: 'Html' }], ['Icon', { url: 'https://github.com/mui/mui-toolpad/issues/83', displayName: 'Icon' }], - ['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/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-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx index d55e3e93c3b..8c27ef3465b 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx @@ -109,7 +109,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-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx index 63506dda393..314c3ce195e 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 { getRectanglePointActiveEdge, @@ -519,20 +521,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 ab8261220ab..5aa69a60d20 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,6 +41,7 @@ export const INTERNAL_COMPONENTS = new Map([ ['Paper', { displayName: 'Paper', builtIn: 'Paper' }], ['Tabs', { displayName: 'Tabs', builtIn: 'Tabs' }], ['Container', { displayName: 'Container', builtIn: 'Container' }], + [FORM_COMPONENT_ID, { displayName: 'Form', builtIn: 'Form' }], ]); function createCodeComponent(domNode: appDom.CodeComponentNode): ToolpadComponentDefinition { @@ -84,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; +} diff --git a/packages/toolpad-components/src/Button.tsx b/packages/toolpad-components/src/Button.tsx index a0b0050f5de..73c1da78719 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 9417365d357..7356dd8b4ab 100644 --- a/packages/toolpad-components/src/DatePicker.tsx +++ b/packages/toolpad-components/src/DatePicker.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; 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 { Controller, FieldError } from 'react-hook-form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; import { SX_PROP_HELPER_TEXT } from './constants.js'; const LOCALE_LOADERS = new Map( @@ -69,13 +71,16 @@ function getSnapshot() { export interface DatePickerProps extends Omit, 'value' | 'onChange' | 'defaultValue'> { value?: string; - onChange?: (newValue: string) => void; + onChange: (newValue: string | null) => void; format: string; fullWidth: boolean; variant: 'outlined' | 'filled' | 'standard'; size: 'small' | 'medium'; sx: any; defaultValue?: string; + name: string; + isRequired: boolean; + isInvalid: boolean; } function DatePicker({ @@ -83,18 +88,46 @@ function DatePicker({ onChange, value: valueProp, defaultValue: defaultValueProp, - ...props + isRequired, + isInvalid, + ...rest }: DatePickerProps) { + const nodeRuntime = useNode(); + + const fieldName = rest.name || nodeRuntime?.nodeName; + + const fallbackName = React.useId(); + const nodeName = fieldName || fallbackName; + + 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: valueProp, + onChange, + defaultValue: defaultValueProp, + emptyValue: null, + validationProps, + }); + const handleChange = React.useMemo( () => onChange - ? (value: dayjs.Dayjs | null) => { + ? (newValue: dayjs.Dayjs | null) => { // date-only form of ISO8601. See https://tc39.es/ecma262/#sec-date-time-string-format - const stringValue = value?.format('YYYY-MM-DD') || ''; - onChange(stringValue); + const stringValue = newValue?.format('YYYY-MM-DD') || ''; + + if (form) { + onFormInputChange(stringValue); + } else { + onChange(stringValue); + } } : undefined, - [onChange], + [form, onChange, onFormInputChange], ); const adapterLocale = React.useSyncExternalStore(subscribeLocaleLoader, getSnapshot); @@ -109,28 +142,52 @@ function DatePicker({ [defaultValueProp], ); + 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 || fieldName || 'Field'; + return ( - - {...props} - format={format || 'L'} - value={value} - onChange={handleChange} - defaultValue={defaultValue} - slotProps={{ - textField: { - fullWidth: props.fullWidth, - variant: props.variant, - size: props.size, - sx: props.sx, - }, - }} - /> + {form && nodeName ? ( + !isInvalid || `${fieldDisplayName} is invalid.`, + }} + render={() => datePickerElement} + /> + ) : ( + datePickerElement + )} ); } -export default createComponent(DatePicker, { +const FormWrappedDatePicker = withComponentForm(DatePicker); + +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: { @@ -156,6 +213,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`', @@ -173,6 +234,16 @@ 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 }, + category: 'validation', + }, + isInvalid: { + helperText: 'Whether the date picker value is invalid.', + typeDef: { type: 'boolean', default: false }, + category: 'validation', + }, 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 bf656b194c3..79026537d3b 100644 --- a/packages/toolpad-components/src/FilePicker.tsx +++ b/packages/toolpad-components/src/FilePicker.tsx @@ -1,6 +1,8 @@ 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 { Controller, FieldError } from 'react-hook-form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; interface FullFile { name: string; @@ -9,9 +11,13 @@ interface FullFile { base64: null | string; } -export type Props = MuiTextFieldProps & { +export type FilePickerProps = MuiTextFieldProps & { multiple: boolean; + value: FullFile[]; onChange: (files: FullFile[]) => void; + name: string; + isRequired: boolean; + isInvalid: boolean; }; const readFile = async (file: Blob): Promise => { @@ -31,7 +37,33 @@ const readFile = async (file: Blob): Promise => { }); }; -function FilePicker({ multiple, onChange, ...props }: Props) { +function FilePicker({ + multiple, + value, + onChange, + isRequired, + isInvalid, + ...rest +}: FilePickerProps) { + const nodeRuntime = useNode(); + + const fieldName = rest.name || nodeRuntime?.nodeName; + + const fallbackName = React.useId(); + const nodeName = fieldName || fallbackName; + + 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 = { @@ -46,22 +78,48 @@ function FilePicker({ multiple, onChange, ...props }: Props) { const files = await Promise.all(filesPromises); - onChange(files); + if (form) { + onFormInputChange(files); + } else { + onChange(files); + } }; - return ( + const filePickerElement = ( ); + + const fieldDisplayName = rest.label || fieldName || 'Field'; + + return form && nodeName ? ( + !isInvalid || `${fieldDisplayName} is invalid.`, + }} + render={() => filePickerElement} + /> + ) : ( + filePickerElement + ); } -export default createComponent(FilePicker, { +const FormWrappedFilePicker = withComponentForm(FilePicker); + +export default createComponent(FormWrappedFilePicker, { helperText: 'File Picker component.\nIt allows users to take select and read files.', argTypes: { value: { @@ -73,6 +131,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 }, @@ -81,6 +143,16 @@ 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 }, + 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/Form.tsx b/packages/toolpad-components/src/Form.tsx new file mode 100644 index 00000000000..4530ed03a7d --- /dev/null +++ b/packages/toolpad-components/src/Form.tsx @@ -0,0 +1,287 @@ +import * as React from 'react'; +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.js'; + +export const FormContext = React.createContext<{ + form: ReturnType | null; + fieldValues: FieldValues; +}>({ + form: null, + fieldValues: {}, +}); + +interface FormProps extends ContainerProps { + value: FieldValues; + onChange: (newValue: FieldValues) => void; + onSubmit?: (data?: FieldValues) => unknown | Promise; + formControlsAlign?: BoxProps['justifyContent']; + formControlsFullWidth?: boolean; + submitButtonText?: string; + hasResetButton?: boolean; + mode?: keyof ValidationMode | undefined; + hasChrome?: boolean; + hideControls?: boolean; +} + +function Form({ + children, + value, + onChange, + onSubmit = () => {}, + hasResetButton = false, + formControlsAlign = 'end', + formControlsFullWidth, + submitButtonText = 'Submit', + mode = 'onSubmit', + hasChrome = true, + hideControls = false, + sx, + ...rest +}: FormProps) { + const form = useForm({ mode }); + 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, isSubmitSuccessful]); + + // Set initial form values + React.useEffect(() => { + onChange(form.getValues()); + }, [form, onChange]); + + React.useEffect(() => { + const formSubscription = form.watch((newValue) => { + onChange(newValue); + }); + return () => formSubscription.unsubscribe(); + }, [form, onChange]); + + const handleReset = React.useCallback(() => { + form.reset(); + }, [form]); + + const formContextValue = React.useMemo( + () => ({ 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, value], + ); + + return ( + + {hasChrome ? ( + +
+ {children} + {!hideControls ? ( + + + {hasResetButton ? ( + + Reset + + ) : null} + + {submitButtonText} + + + + ) : null} +
+
+ ) : ( + children + )} +
+ ); +} + +interface UseFormInputInput { + name: string; + 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) { + form.setValue(name, newValue, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + onChange(newValue); + } + }, + [form, name, onChange], + ); + + const previousDefaultValueRef = React.useRef(defaultValue); + React.useEffect(() => { + if (form && defaultValue !== previousDefaultValueRef.current) { + onChange(defaultValue as V); + form.setValue(name, defaultValue); + previousDefaultValueRef.current = defaultValue; + } + }, [form, name, onChange, defaultValue]); + + const isInitialForm = Object.keys(fieldValues).length === 0; + + React.useEffect(() => { + if (form) { + 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 previousNodeNameRef = React.useRef(name); + React.useEffect(() => { + const previousNodeName = previousNodeNameRef.current; + + if (form && previousNodeName !== name) { + form.unregister(previousNodeName); + previousNodeNameRef.current = name; + } + }, [form, name]); + + const previousManualValidationPropsRef = React.useRef(validationProps); + React.useEffect(() => { + if ( + form && + !_.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

, +) { + 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: { + typeDef: { type: 'element' }, + control: { type: 'layoutSlot' }, + }, + value: { + helperText: 'The value that is controlled by this text input.', + typeDef: { type: 'object', default: {} }, + onChangeProp: 'onChange', + }, + onSubmit: { + 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' }, + }, + 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/Paper.tsx b/packages/toolpad-components/src/Paper.tsx index 94b5c488985..925312f6008 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.js'; -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 4c5543485e4..69d1f3a6d56 100644 --- a/packages/toolpad-components/src/Select.tsx +++ b/packages/toolpad-components/src/Select.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { TextFieldProps, MenuItem, TextField } from '@mui/material'; -import { createComponent } from '@mui/toolpad-core'; +import { createComponent, useNode } from '@mui/toolpad-core'; +import { FieldError, Controller } from 'react-hook-form'; +import { FormContext, useFormInput, withComponentForm } from './Form.js'; import { SX_PROP_HELPER_TEXT } from './constants.js'; export interface SelectOption { @@ -11,29 +13,62 @@ export interface SelectOption { export type SelectProps = Omit & { value: string; onChange: (newValue: string) => void; + defaultValue: string; options: (string | SelectOption)[]; + name: string; + isRequired: boolean; + isInvalid: boolean; }; -function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest }: SelectProps) { +function Select({ + options, + value, + onChange, + fullWidth, + sx, + defaultValue, + isRequired, + isInvalid, + ...rest +}: SelectProps) { + const nodeRuntime = useNode(); + + const fieldName = rest.name || nodeRuntime?.nodeName; + + const fallbackName = React.useId(); + const nodeName = fieldName || fallbackName; + + 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) => { - onChange(event.target.value); + const newValue = event.target.value; + + if (form) { + onFormInputChange(newValue); + } else { + onChange(newValue); + } }, - [onChange], + [form, onChange, onFormInputChange], ); - const id = React.useId(); - - 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 ( @@ -41,12 +76,47 @@ function Select({ options, value, onChange, defaultValue, fullWidth, sx, ...rest {String(parsedOption.label ?? parsedOption.value)} ); + }), + [id, options], + ); + + const selectElement = ( + + {renderedOptions} ); + + const fieldDisplayName = rest.label || fieldName || 'Field'; + + return form && nodeName ? ( + !isInvalid || `${fieldDisplayName} is invalid.`, + }} + render={() => selectElement} + /> + ) : ( + selectElement + ); } -export default createComponent(Select, { +const FormWrappedSelect = withComponentForm(Select); + +export default createComponent(FormWrappedSelect, { helperText: 'The Select component lets you select a value from a set of options.', layoutDirection: 'both', loadingPropSource: ['value', 'options'], @@ -71,6 +141,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`', @@ -92,6 +166,16 @@ 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 }, + category: 'validation', + }, + isInvalid: { + helperText: 'Whether the select value is invalid.', + typeDef: { type: 'boolean', default: false }, + category: 'validation', + }, sx: { helperText: SX_PROP_HELPER_TEXT, typeDef: { type: 'object' }, 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; onChange: (newValue: string) => void; + defaultValue: string; alignItems?: BoxProps['alignItems']; justifyContent?: BoxProps['justifyContent']; + name: string; + isRequired: boolean; + minLength: number; + maxLength: number; + isInvalid: boolean; }; -function TextField({ defaultValue, onChange, ...props }: TextFieldProps) { +function TextField({ + defaultValue, + onChange, + value, + isRequired, + minLength, + maxLength, + isInvalid, + ...rest +}: TextFieldProps) { + const nodeRuntime = useNode(); + + const fieldName = rest.name || nodeRuntime?.nodeName; + + const fallbackName = React.useId(); + const nodeName = fieldName || fallbackName; + + 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) => { - onChange(event.target.value); + const newValue = event.target.value; + + if (form) { + onFormInputChange(newValue); + } else { + onChange(newValue); + } }, - [onChange], + [form, onChange, onFormInputChange], + ); + + const textFieldElement = ( + ); - return ; + const fieldDisplayName = rest.label || fieldName || 'Field'; + + return form && nodeName ? ( + !isInvalid || `${fieldDisplayName} is invalid.`, + }} + render={() => textFieldElement} + /> + ) : ( + textFieldElement + ); } -export default createComponent(TextField, { +const FormWrappedTextField = withComponentForm(TextField); + +export default createComponent(FormWrappedTextField, { helperText: 'The TextField component lets you input a text value.', layoutDirection: 'both', argTypes: { @@ -43,6 +130,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`', @@ -60,6 +151,26 @@ 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 }, + 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, typeDef: { type: 'object' }, diff --git a/packages/toolpad-components/src/index.tsx b/packages/toolpad-components/src/index.tsx index aa818e4d896..0fbb79b2940 100644 --- a/packages/toolpad-components/src/index.tsx +++ b/packages/toolpad-components/src/index.tsx @@ -28,6 +28,8 @@ 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, diff --git a/packages/toolpad-core/src/runtime.tsx b/packages/toolpad-core/src/runtime.tsx index 5e1e9c36328..4493dfdba43 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>(new Emitter()); // 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/packages/toolpad-core/src/types.ts b/packages/toolpad-core/src/types.ts index 0d67a4c31ce..fb7b472fa6e 100644 --- a/packages/toolpad-core/src/types.ts +++ b/packages/toolpad-core/src/types.ts @@ -217,7 +217,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; tsType?: string; } diff --git a/test/integration/bindings/fixture-navigation/toolpad/.generated/functions/toolpad_main.js b/test/integration/bindings/fixture-navigation/toolpad/.generated/functions/toolpad_main.js new file mode 100644 index 00000000000..6e0923218d9 --- /dev/null +++ b/test/integration/bindings/fixture-navigation/toolpad/.generated/functions/toolpad_main.js @@ -0,0 +1,104 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// toolpad:main.ts +var import_server = require("@mui/toolpad-core/server"); +var import_errors = require("@mui/toolpad-utils/errors"); +var import_node_fetch = __toESM(require("node-fetch")); +if (!global.fetch) { + global.fetch = import_node_fetch.default; + global.Headers = import_node_fetch.Headers; + global.Request = import_node_fetch.Request; + global.Response = import_node_fetch.Response; +} +var resolversPromise; +async function getResolvers() { + if (!resolversPromise) { + resolversPromise = (async () => { + const functions = await import("./toolpad/resources/functions.ts").catch((err) => { + console.error(err); + return {}; + }); + const functionsFileResolvers = Object.entries(functions).flatMap(([name, resolver]) => { + return typeof resolver === "function" ? [[name, resolver]] : []; + }); + return new Map(functionsFileResolvers); + })(); + } + return resolversPromise; +} +async function loadResolver(name) { + const resolvers = await getResolvers(); + const resolver = resolvers.get(name); + if (!resolver) { + throw new Error(`Can't find "${name}"`); + } + return resolver; +} +async function execResolver(name, parameters) { + const resolver = await loadResolver(name); + return resolver({ parameters }); +} +process.on("message", async (msg) => { + switch (msg.kind) { + case "exec": { + let data, error; + try { + data = await execResolver(msg.name, msg.parameters); + } catch (err) { + error = (0, import_errors.serializeError)((0, import_errors.errorFrom)(err)); + } + process.send({ + kind: "result", + id: msg.id, + data, + error + }); + break; + } + case "introspect": { + let data, error; + try { + const resolvers = await getResolvers(); + const resolvedResolvers = Array.from(resolvers, ([name, resolver]) => [ + name, + resolver[import_server.TOOLPAD_FUNCTION] || {} + ]); + data = { + functions: Object.fromEntries(resolvedResolvers.filter(Boolean)) + }; + } catch (err) { + error = (0, import_errors.serializeError)((0, import_errors.errorFrom)(err)); + } + process.send({ + kind: "result", + id: msg.id, + data, + error + }); + break; + } + default: + console.log(`Unknown message kind "${msg.kind}"`); + } +}); diff --git a/test/integration/components/fixture-form/toolpad/.gitignore b/test/integration/components/fixture-form/toolpad/.gitignore new file mode 100644 index 00000000000..5f1e4d07bfd --- /dev/null +++ b/test/integration/components/fixture-form/toolpad/.gitignore @@ -0,0 +1 @@ +.generated diff --git a/test/integration/components/fixture-form/toolpad/pages/form/page.yml b/test/integration/components/fixture-form/toolpad/pages/form/page.yml new file mode 100644 index 00000000000..5d417c08a94 --- /dev/null +++ b/test/integration/components/fixture-form/toolpad/pages/form/page.yml @@ -0,0 +1,78 @@ +apiVersion: v1 +kind: page +spec: + id: 5w03xpv + title: form + display: shell + content: + - component: PageRow + name: pageRow + children: + - component: Form + name: form + props: + hasResetButton: true + onSubmit: + $$jsExpressionAction: button.content = 'Submitted' + children: + - component: PageRow + name: pageRow1 + children: + - component: PageColumn + name: pageColumn + layout: + columnSize: 1 + children: + - component: TextField + name: textField + props: + label: name + name: name + defaultValue: Default Name + minLength: 3 + - component: DatePicker + name: datePicker + props: + name: date + label: date + isRequired: true + - component: Select + name: select + props: + label: option + name: option + options: + - option 1 + - option 2 + - option 3 + isInvalid: + $$jsExpression: | + select.value === "option 1" + - component: FilePicker + name: filePicker + props: + name: file + label: file + - component: PageRow + name: pageRow2 + children: + - component: Text + name: text + props: + value: + $$jsExpression: | + `My form data: ${JSON.stringify(form.value || {})}` + - component: PageRow + name: pageRow3 + children: + - component: Button + name: button + - component: PageRow + name: pageRow4 + children: + - component: TextField + name: textField1 + props: + name: outside + maxLength: 3 + label: outside diff --git a/test/integration/components/fixture-form/toolpad/resources/functions.ts b/test/integration/components/fixture-form/toolpad/resources/functions.ts new file mode 100644 index 00000000000..e787504fe38 --- /dev/null +++ b/test/integration/components/fixture-form/toolpad/resources/functions.ts @@ -0,0 +1,9 @@ +// Toolpad queries: + +export async function example() { + return [ + { firstname: 'Nell', lastName: 'Lester' }, + { firstname: 'Keanu', lastName: 'Walter' }, + { firstname: 'Daniella', lastName: 'Sweeney' }, + ]; +} diff --git a/test/integration/components/form.spec.ts b/test/integration/components/form.spec.ts new file mode 100644 index 00000000000..42f18070cda --- /dev/null +++ b/test/integration/components/form.spec.ts @@ -0,0 +1,97 @@ +import * as path from 'path'; +import { ToolpadRuntime } from '../../models/ToolpadRuntime'; +import { test, expect } from '../../playwright/localTest'; + +test.use({ + localAppConfig: { + template: path.resolve(__dirname, './fixture-form'), + cmd: 'dev', + }, +}); + +test('submits form data', async ({ page }) => { + const runtimeModel = new ToolpadRuntime(page); + await runtimeModel.gotoPage('form'); + + const nameInput = page.getByLabel('name'); + await nameInput.clear(); + await nameInput.type('Toolpad'); + + const dateInput = page.getByLabel('date', { exact: true }); + await dateInput.focus(); + await dateInput.type('01011990'); + + await page.getByLabel('option').click(); + await page.getByRole('option', { name: 'option 2' }).click(); + + const testFilePath = path.resolve(__dirname, './test.txt'); + await page.getByLabel('file').setInputFiles(testFilePath); + + await expect(page.getByText('My form data')).toContainText( + JSON.stringify({ + name: 'Toolpad', + date: '1990-01-01', + option: 'option 2', + file: [ + { + name: 'test.txt', + type: 'text/plain', + size: 6, + base64: 'data:text/plain;base64,d29ya3MK', + }, + ], + }), + ); + + await expect(page.getByRole('button', { name: 'Submitted' })).not.toBeVisible(); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByRole('button', { name: 'Submitted' })).toBeVisible(); +}); + +test('resets form data', async ({ page }) => { + const runtimeModel = new ToolpadRuntime(page); + await runtimeModel.gotoPage('form'); + + const nameInput = page.getByLabel('name'); + await nameInput.clear(); + await nameInput.type('MUI'); + + const dateInput = page.getByLabel('date', { exact: true }); + await dateInput.focus(); + await dateInput.type('05051995'); + + await page.getByLabel('option').click(); + await page.getByRole('option', { name: 'option 3' }).click(); + + const testFilePath = path.resolve(__dirname, './test.txt'); + await page.getByLabel('file').setInputFiles(testFilePath); + + await page.getByRole('button', { name: 'Reset' }).click(); + + await expect(page.getByText('My form data')).toContainText( + JSON.stringify({ + name: 'Default Name', + }), + ); +}); + +test('validates form data', async ({ page }) => { + const runtimeModel = new ToolpadRuntime(page); + await runtimeModel.gotoPage('form'); + + const nameInput = page.getByLabel('name'); + await nameInput.clear(); + await nameInput.type('OK'); + + await page.getByLabel('option').click(); + await page.getByRole('option', { name: 'option 1' }).click(); + + await page.getByLabel('outside').type('toolong'); + + await page.getByRole('button', { name: 'Submit' }).click(); + + await expect(page.getByText('name must have at least 3 characters.')).toBeVisible(); + await expect(page.getByText('date is required.')).toBeVisible(); + await expect(page.getByText('option is invalid.')).toBeVisible(); + await expect(page.getByText('outside must have no more than 3 characters.')).toBeVisible(); +}); diff --git a/test/integration/components/test.txt b/test/integration/components/test.txt new file mode 100644 index 00000000000..153d19401bc --- /dev/null +++ b/test/integration/components/test.txt @@ -0,0 +1 @@ +works diff --git a/test/integration/data-grid/basic.spec.ts b/test/integration/data-grid/basic.spec.ts index 19c51bd3e0e..1d9a8eb05ac 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/editor/new.spec.ts b/test/integration/editor/new.spec.ts index b684c8294b8..b1ec358fd40 100644 --- a/test/integration/editor/new.spec.ts +++ b/test/integration/editor/new.spec.ts @@ -28,7 +28,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 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/basic.spec.ts b/test/integration/propControls/basic.spec.ts index 4f6fd4cd202..3a855817ccf 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();