Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve columns editor UX #2570

Merged
merged 2 commits into from
Aug 30, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 194 additions & 116 deletions packages/toolpad-app/src/toolpad/propertyControls/GridColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
Popover,
Stack,
TextField,
TextFieldProps,
TextFieldVariants,
Tooltip,
} from '@mui/material';
import * as React from 'react';
Expand Down Expand Up @@ -48,6 +50,193 @@ const COLUMN_TYPES: string[] = [
];
const ALIGNMENTS: GridAlignment[] = ['left', 'right', 'center'];

type ImmediateInputProps<V extends TextFieldVariants = TextFieldVariants> = TextFieldProps<V> & {
validate?: (input: string) => string | null;
};

interface ImmediateInputState {
input: string;
error: string | null;
}

function useImmediateTextField<V extends TextFieldVariants = TextFieldVariants>(
props: ImmediateInputProps<V>,
): TextFieldProps<V> {
const { value, onChange, error, helperText, required, onBlur, validate } = props;
const createInputState = React.useCallback(
(rawInput: unknown): ImmediateInputState => {
const input = String(rawInput);
let inputError = null;
if (required && !input) {
inputError = 'Input required';
} else if (validate) {
inputError = validate(input);
}
return { input, error: inputError };
},
[validate, required],
);
const [state, setState] = React.useState<ImmediateInputState>(createInputState(value));
React.useEffect(() => {
setState(createInputState(value));
}, [value, createInputState]);

return {
...props,
value: state.input,
error: !!state.error || error,
helperText: state.error || helperText,
required,
onBlur: (event) => {
if (state.input !== value) {
setState(createInputState(value));
}
onBlur?.(event);
},
onChange: (event) => {
const newState = createInputState(event.target.value);
setState(newState);
if (!newState.error) {
onChange?.(event);
}
},
};
}

interface GridColumnEditorProps {
value: SerializableGridColumn;
onChange: (newValue: SerializableGridColumn) => void;
disabled?: boolean;
}

function GridColumnEditor({
disabled,
value: editedColumn,
onChange: handleColumnChange,
}: GridColumnEditorProps) {
const { dom } = useDom();
const toolpadComponents = useToolpadComponents(dom);
const codeComponents: ToolpadComponentDefinition[] = React.useMemo(() => {
return Object.values(toolpadComponents)
.filter(Boolean)
.filter((definition) => !definition.builtIn);
}, [toolpadComponents]);

const fieldInput = useImmediateTextField({
label: 'field',
disabled,
required: true,
value: editedColumn.field,
onChange: (event) => {
handleColumnChange({ ...editedColumn, field: event.target.value });
},
});

return (
<Stack gap={1} py={1}>
<TextField {...fieldInput} />

<TextField
label="header"
value={editedColumn.headerName || ''}
disabled={disabled}
onChange={(event) =>
handleColumnChange({
...editedColumn,
headerName: event.target.value ? event.target.value : undefined,
})
}
/>

<TextField
select
fullWidth
label="align"
value={editedColumn.align ?? ''}
disabled={disabled}
onChange={(event) =>
handleColumnChange({
...editedColumn,
align: (event.target.value as GridAlignment) || undefined,
})
}
>
{ALIGNMENTS.map((alignment) => (
<MenuItem key={alignment} value={alignment}>
{alignment}
</MenuItem>
))}
</TextField>

<TextField
label="width"
type="number"
value={editedColumn.width}
disabled={disabled}
onChange={(event) =>
handleColumnChange({ ...editedColumn, width: Number(event.target.value) })
}
/>

<TextField
select
fullWidth
label="type"
value={editedColumn.type ?? ''}
disabled={disabled}
onChange={(event) =>
handleColumnChange({
...editedColumn,
type: event.target.value,
numberFormat: undefined,
})
}
>
{COLUMN_TYPES.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</TextField>

<Box sx={{ ml: 1, pl: 1, borderLeft: 1, borderColor: 'divider' }}>
{editedColumn.type === 'number' ? (
<NumberFormatEditor
disabled={disabled}
value={editedColumn.numberFormat}
onChange={(numberFormat) => handleColumnChange({ ...editedColumn, numberFormat })}
/>
) : null}

{editedColumn.type === 'codeComponent' ? (
<TextField
select
required
fullWidth
label="Custom component"
value={editedColumn.codeComponent ?? ''}
disabled={disabled}
error={!editedColumn.codeComponent}
helperText={editedColumn.codeComponent ? undefined : 'Please select a component'}
onChange={(event) =>
handleColumnChange({
...editedColumn,
codeComponent: event.target.value,
})
}
>
{codeComponents.map(({ displayName }) => (
<MenuItem key={displayName} value={displayName}>
{displayName}
</MenuItem>
))}
</TextField>
) : null}
</Box>
</Stack>
);
}

function GridColumnsPropEditor({
propType,
label,
Expand All @@ -58,13 +247,6 @@ function GridColumnsPropEditor({
}: EditorProps<SerializableGridColumns>) {
const { bindings } = usePageEditorState();
const [editedIndex, setEditedIndex] = React.useState<number | null>(null);
const { dom } = useDom();
const toolpadComponents = useToolpadComponents(dom);
const codeComponents: ToolpadComponentDefinition[] = React.useMemo(() => {
return Object.values(toolpadComponents)
.filter(Boolean)
.filter((definition) => !definition.builtIn);
}, [toolpadComponents]);

const editedColumn = typeof editedIndex === 'number' ? value[editedIndex] : null;

Expand Down Expand Up @@ -177,115 +359,11 @@ function GridColumnsPropEditor({
<IconButton aria-label="Back" onClick={() => setEditedIndex(null)}>
<ArrowBackIcon />
</IconButton>
<Stack gap={1} py={1}>
<TextField
label="field"
value={editedColumn.field}
disabled={disabled}
onChange={(event) =>
handleColumnChange({ ...editedColumn, field: event.target.value })
}
/>

<TextField
label="header"
value={editedColumn.headerName}
disabled={disabled}
onChange={(event) =>
handleColumnChange({ ...editedColumn, headerName: event.target.value })
}
/>

<TextField
select
fullWidth
label="align"
value={editedColumn.align ?? ''}
disabled={disabled}
onChange={(event) =>
handleColumnChange({
...editedColumn,
align: (event.target.value as GridAlignment) || undefined,
})
}
>
{ALIGNMENTS.map((alignment) => (
<MenuItem key={alignment} value={alignment}>
{alignment}
</MenuItem>
))}
</TextField>

<TextField
label="width"
type="number"
value={editedColumn.width}
disabled={disabled}
onChange={(event) =>
handleColumnChange({ ...editedColumn, width: Number(event.target.value) })
}
/>

<TextField
select
fullWidth
label="type"
value={editedColumn.type ?? ''}
disabled={disabled}
onChange={(event) =>
handleColumnChange({
...editedColumn,
type: event.target.value,
numberFormat: undefined,
})
}
>
{COLUMN_TYPES.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</TextField>

<Box sx={{ ml: 1, pl: 1, borderLeft: 1, borderColor: 'divider' }}>
{editedColumn.type === 'number' ? (
<NumberFormatEditor
disabled={disabled}
value={editedColumn.numberFormat}
onChange={(numberFormat) =>
handleColumnChange({ ...editedColumn, numberFormat })
}
/>
) : null}

{editedColumn.type === 'codeComponent' ? (
<TextField
select
required
fullWidth
label="Custom component"
value={editedColumn.codeComponent ?? ''}
disabled={disabled}
error={!editedColumn.codeComponent}
helperText={
editedColumn.codeComponent ? undefined : 'Please select a component'
}
onChange={(event) =>
handleColumnChange({
...editedColumn,
codeComponent: event.target.value,
})
}
>
{codeComponents.map(({ displayName }) => (
<MenuItem key={displayName} value={displayName}>
{displayName}
</MenuItem>
))}
</TextField>
) : null}
</Box>
</Stack>
<GridColumnEditor
value={editedColumn}
onChange={handleColumnChange}
disabled={disabled}
/>
</React.Fragment>
) : (
<React.Fragment>
Expand Down