Skip to content

Commit

Permalink
Form component (#1926)
Browse files Browse the repository at this point in the history
Signed-off-by: Jan Potoms <2109932+Janpot@users.noreply.github.com>
Co-authored-by: bytasv <vytautas.butkus@gmail.com>
Co-authored-by: Jan Potoms <2109932+Janpot@users.noreply.github.com>
  • Loading branch information
3 people authored Apr 24, 2023
1 parent b17aab1 commit 854056d
Show file tree
Hide file tree
Showing 29 changed files with 1,075 additions and 102 deletions.
9 changes: 9 additions & 0 deletions packages/toolpad-app/src/appDom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/runtime/ToolpadApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ function RenderedNodeContent({ node, childNodeGroups, Component }: RenderedNodeC
return (
<NodeRuntimeWrapper
nodeId={nodeId}
nodeName={node.name}
componentConfig={Component[TOOLPAD_COMPONENT]}
NodeError={NodeError}
>
Expand Down Expand Up @@ -1085,7 +1086,6 @@ function RenderedPage({ nodeId }: RenderedNodeProps) {
childNodeGroups={{ children }}
Component={PageRootComponent}
/>

{queries.map((node) => (
<FetchNode key={node.id} page={page} node={node} />
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default function NodeNameEditor({ node, sx }: NodeNameEditorProps) {
<TextField
sx={sx}
fullWidth
label="name"
label="Node name"
error={!isNameValid}
helperText={nodeNameError}
value={nameInput}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const FUTURE_COMPONENTS = new Map<string, FutureComponentSpec>([
['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' }],
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -97,6 +98,11 @@ function ComponentPropsEditor<P extends object>({
);
}, [bindings, node.id]);

const argTypesByCategory = _.groupBy(
Object.entries(componentConfig.argTypes || {}) as ExactEntriesOf<ArgTypeDefinitions<P>>,
([, propTypeDef]) => propTypeDef?.category || 'properties',
);

return (
<React.Fragment>
{hasLayoutControls ? (
Expand All @@ -121,24 +127,26 @@ function ComponentPropsEditor<P extends object>({
<Divider sx={{ mt: 1 }} />
</React.Fragment>
) : null}
<Typography variant="overline" className={classes.sectionHeading}>
Properties:
</Typography>
{(
Object.entries(componentConfig.argTypes || {}) as ExactEntriesOf<ArgTypeDefinitions<P>>
).map(([propName, propTypeDef]) =>
propTypeDef && shouldRenderControl(propTypeDef, propName, props, componentConfig) ? (
<div key={propName} className={classes.control}>
<NodeAttributeEditor
node={node}
namespace="props"
props={props}
name={propName}
argType={propTypeDef}
/>
</div>
) : null,
)}
{Object.entries(argTypesByCategory).map(([category, argTypeEntries]) => (
<React.Fragment key={category}>
<Typography variant="overline" className={classes.sectionHeading}>
{category}:
</Typography>
{argTypeEntries.map(([propName, propTypeDef]) =>
propTypeDef && shouldRenderControl(propTypeDef, propName, props, componentConfig) ? (
<div key={propName} className={classes.control}>
<NodeAttributeEditor
node={node}
namespace="props"
props={props}
name={propName}
argType={propTypeDef}
/>
</div>
) : null,
)}
</React.Fragment>
))}
</React.Fragment>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default function EditorCanvasHost({
const [editorOverlayRoot, setEditorOverlayRoot] = React.useState<HTMLElement | null>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
isPageColumn,
PAGE_ROW_COMPONENT_ID,
PAGE_COLUMN_COMPONENT_ID,
isFormComponent,
FORM_COMPONENT_ID,
} from '../../../../toolpadComponents';
import {
getRectanglePointActiveEdge,
Expand Down Expand Up @@ -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<appDom.AppDomNode>([selectedNode, ...appDom.getDescendants(dom, selectedNode)])
: new Set();
let excludedNodes = new Set();

if (selectedNode && !newNode) {
excludedNodes = new Set<appDom.AppDomNode>([
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]);
Expand Down
6 changes: 6 additions & 0 deletions packages/toolpad-app/src/toolpadComponents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type InstantiatedComponents = Record<string, InstantiatedComponent | unde
export const PAGE_ROW_COMPONENT_ID = 'PageRow';
export const PAGE_COLUMN_COMPONENT_ID = 'PageColumn';
export const STACK_COMPONENT_ID = 'Stack';
export const FORM_COMPONENT_ID = 'Form';

export const INTERNAL_COMPONENTS = new Map<string, ToolpadComponentDefinition>([
[PAGE_ROW_COMPONENT_ID, { displayName: 'Row', builtIn: 'PageRow', system: true }],
Expand All @@ -40,6 +41,7 @@ export const INTERNAL_COMPONENTS = new Map<string, ToolpadComponentDefinition>([
['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 {
Expand Down Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions packages/toolpad-components/src/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
117 changes: 94 additions & 23 deletions packages/toolpad-components/src/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -69,32 +71,63 @@ function getSnapshot() {
export interface DatePickerProps
extends Omit<DesktopDatePickerProps<dayjs.Dayjs>, '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({
format,
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<string | null>({
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);
Expand All @@ -109,28 +142,52 @@ function DatePicker({
[defaultValueProp],
);

const datePickerElement = (
<DesktopDatePicker<dayjs.Dayjs>
{...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 (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={adapterLocale}>
<DesktopDatePicker<dayjs.Dayjs>
{...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 ? (
<Controller
name={nodeName}
control={form.control}
rules={{
required: isRequired ? `${fieldDisplayName} is required.` : false,
validate: () => !isInvalid || `${fieldDisplayName} is invalid.`,
}}
render={() => datePickerElement}
/>
) : (
datePickerElement
)}
</LocalizationProvider>
);
}

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: {
Expand All @@ -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`',
Expand All @@ -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' },
Expand Down
Loading

0 comments on commit 854056d

Please sign in to comment.