From 768340b68243203c26a8712cd4f22e983bec8fda Mon Sep 17 00:00:00 2001 From: Rodrigo Dias Date: Fri, 4 Oct 2024 13:30:52 -0300 Subject: [PATCH 1/4] feat: multiselect (wip) --- .../MultiSelectField/MultiSelectField.tsx | 157 ++++++++++++++++++ .../src/components/MultiSelectField/index.ts | 1 + packages/react-material-ui/src/index.ts | 4 + 3 files changed, 162 insertions(+) create mode 100644 packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx create mode 100644 packages/react-material-ui/src/components/MultiSelectField/index.ts diff --git a/packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx b/packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx new file mode 100644 index 00000000..42ebc785 --- /dev/null +++ b/packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx @@ -0,0 +1,157 @@ +import React, { useState, useEffect } from 'react'; +import { + Checkbox, + FormControl, + InputLabel, + ListItemText, + MenuItem, + Select, + SelectChangeEvent, + SelectProps, +} from '@mui/material'; + +import { FormFieldSkeleton } from '../FormFieldSkeleton'; + +/** + * Default option representing "All" in the select field. + */ +export const allOption: SelectOption = { + value: 'all', + label: 'All', +}; + +/** + * Option type used in the MultiSelectField component. + */ +export type SelectOption = { + /** The value of the option */ + value: string; + /** The label to display for the option */ + label: string; +}; + +/** + * MultiSelectField component props. + */ +export type MultiSelectFieldProps = { + /** Array of options to display in the select dropdown */ + options: SelectOption[]; + /** The default selected value */ + defaultValue?: string[]; + /** Whether to include the "All" option in the dropdown. True by default. */ + hasAllOption?: boolean; + /** Whether the component is in a loading state */ + isLoading?: boolean; + /** Callback function triggered when the selected value changes */ + onChange: (value: string[]) => void; + /** Wether the component show show a "Confirm" button to save changes */ + showConfirmButton?: boolean; +} & Omit, 'onChange'>; + +export const MultiSelectField = ({ + options = [], + defaultValue, + hasAllOption = true, + isLoading = false, + label, + onChange, + fullWidth, + size, + variant = 'outlined', + value, + showConfirmButton, + ...rest +}: MultiSelectFieldProps) => { + const [_value, _setValue] = useState(value || defaultValue || []); + + useEffect(() => { + // Array.isArray(value) && _value !== value && _setValue(value); + Array.isArray(value) && _setValue(value); + }, [value]); + + const handleChange = (event: SelectChangeEvent) => { + const { + target: { value }, + } = event; + // On autofill we get a stringified value. + let values = typeof value === 'string' ? value.split(',') : value; + + if (values.includes(allOption.value) && hasAllOption) { + values = []; + } + + // TODO handle allOption click + // value === allOption.value ? [] : value; + { + showConfirmButton ? _setValue(values) : onChange(values); + } + }; + + const finalOptions = [...(hasAllOption ? [allOption] : []), ...options]; + + const renderValue = (selected: string[]) => { + if (selected.length === 0 && hasAllOption) return allOption.label; + + let valueString: string = selected + .map( + (selectedItem: string) => + options?.find((item) => item.value === selectedItem)?.label, + ) + .join(', '); + + return valueString; + }; + + return ( + + + + {label} + + + + + ); +}; diff --git a/packages/react-material-ui/src/components/MultiSelectField/index.ts b/packages/react-material-ui/src/components/MultiSelectField/index.ts new file mode 100644 index 00000000..9fc45806 --- /dev/null +++ b/packages/react-material-ui/src/components/MultiSelectField/index.ts @@ -0,0 +1 @@ +export { MultiSelectField, MultiSelectFieldProps } from './MultiSelectField'; diff --git a/packages/react-material-ui/src/index.ts b/packages/react-material-ui/src/index.ts index 60e76bf8..e8a074c9 100644 --- a/packages/react-material-ui/src/index.ts +++ b/packages/react-material-ui/src/index.ts @@ -64,6 +64,10 @@ export { TextField, TextFieldProps } from './components/TextField'; export { default as SearchField } from './components/SearchField'; export { default as AutocompleteField } from './components/AutocompleteField'; export { SelectField, SelectFieldProps } from './components/SelectField'; +export { + MultiSelectField, + MultiSelectFieldProps, +} from './components/MultiSelectField'; export { default as SimpleForm } from './components/SimpleForm'; export { Filter, From 3ff27d3d8db0f8306ce2a6798f8491be27003828 Mon Sep 17 00:00:00 2001 From: Rodrigo Dias Date: Mon, 7 Oct 2024 20:37:54 -0300 Subject: [PATCH 2/4] feat: multiselect - field and widget --- .../components/MultiSelect/MultiSelect.tsx | 212 ++++++++++++++++++ .../index.ts | 0 .../MultiSelectField/MultiSelectField.tsx | 157 ------------- .../CustomWidgets/CustomMultiSelectWidget.tsx | 73 ++++++ 4 files changed, 285 insertions(+), 157 deletions(-) create mode 100644 packages/react-material-ui/src/components/MultiSelect/MultiSelect.tsx rename packages/react-material-ui/src/components/{MultiSelectField => MultiSelect}/index.ts (100%) delete mode 100644 packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx create mode 100644 packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx diff --git a/packages/react-material-ui/src/components/MultiSelect/MultiSelect.tsx b/packages/react-material-ui/src/components/MultiSelect/MultiSelect.tsx new file mode 100644 index 00000000..54f25826 --- /dev/null +++ b/packages/react-material-ui/src/components/MultiSelect/MultiSelect.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { + Box, + Checkbox, + Chip, + FormControl, + InputLabel, + ListItemText, + MenuItem, + Select, + SelectChangeEvent, + SelectProps, +} from '@mui/material'; + +import { FormFieldSkeleton } from '../FormFieldSkeleton'; +import { FormLabel } from '../FormLabel'; + +import { TextProps } from 'interfaces'; + +/** + * Default option representing "All" in the select field. + */ +export const allOption: SelectOption = { + value: 'all', + label: 'All', +}; + +/** + * Option type used in the MultiSelect component. + */ +export type SelectOption = { + /** The value of the option */ + value: string; + /** The label to display for the option */ + label: string; +}; + +/** + * MultiSelect component props. + */ +export type MultiSelectProps = { + /** Array of options to display in the select dropdown */ + options: SelectOption[]; + /** The default selected value */ + defaultValue?: string[]; + /** Whether to include the "All" option in the dropdown. True by default. */ + hasAllOption?: boolean; + /** Whether the component is in a loading state */ + isLoading?: boolean; + /** Callback function triggered when the selected value changes */ + onChange: (value: string[]) => void; + /** Wheter to show the default input display or the chips style */ + displayVariant?: 'default' | 'chips'; + /** Wheter to display the default shrinking label or Rockets style, above the field */ + labelVariant?: 'default' | 'rockets'; + /** Additional properties to pass to the `Text` component used for rendering the label */ + labelProps?: TextProps; +} & Omit, 'onChange'>; + +export const MultiSelect = ({ + options = [], + defaultValue, + hasAllOption = true, + isLoading = false, + label, + placeholder, + onChange, + fullWidth, + size, + variant = 'outlined', + value, + required, + displayVariant = 'default', + labelVariant = 'default', + name, + labelProps, + ...rest +}: MultiSelectProps) => { + const handleChange = (event: SelectChangeEvent) => { + const { + target: { value }, + } = event; + // On autofill we get a stringified value. + let values = typeof value === 'string' ? value.split(',') : value; + + if (values.includes(allOption.value) && hasAllOption) { + values = []; + } + + onChange(values); + }; + + const removeValue = (id: string) => { + const valueIndex = value?.indexOf(id); + if (!value || typeof valueIndex !== 'number' || valueIndex === -1) return; + const newValue = [...value]; + newValue.splice(valueIndex, 1); + onChange(newValue); + }; + + const finalOptions = [...(hasAllOption ? [allOption] : []), ...options]; + + const renderValues = (selected?: string[]) => { + const isChips = displayVariant === 'chips'; + const valuesIds: string[] = selected || (value as string[]) || []; + + const valueLabels = valuesIds.map( + (selectedItem: string) => + options?.find((item) => item.value === selectedItem)?.label, + ); + + if (isChips) { + return valueLabels.map((label, index) => ( + removeValue(valuesIds[index])} + sx={{ + borderRadius: '6px', + mt: 1, + '&:not(:last-child)': { mr: 1 }, + }} + /> + )); + } + + return valueLabels.join(', '); + }; + + const renderInputValue = (selected: string[]) => { + if (displayVariant === 'chips') { + return placeholder || label; + } + + if (selected.length === 0 && hasAllOption) return allOption.label; + + return renderValues(selected); + }; + + const labelId = `label-${name}`; + return ( + + + {labelVariant === 'default' && ( + + {label} + + )} + + {labelVariant === 'rockets' && label && typeof label === 'string' && ( + + )} + + + {displayVariant === 'chips' && {renderValues()}} + + + ); +}; diff --git a/packages/react-material-ui/src/components/MultiSelectField/index.ts b/packages/react-material-ui/src/components/MultiSelect/index.ts similarity index 100% rename from packages/react-material-ui/src/components/MultiSelectField/index.ts rename to packages/react-material-ui/src/components/MultiSelect/index.ts diff --git a/packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx b/packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx deleted file mode 100644 index 42ebc785..00000000 --- a/packages/react-material-ui/src/components/MultiSelectField/MultiSelectField.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - Checkbox, - FormControl, - InputLabel, - ListItemText, - MenuItem, - Select, - SelectChangeEvent, - SelectProps, -} from '@mui/material'; - -import { FormFieldSkeleton } from '../FormFieldSkeleton'; - -/** - * Default option representing "All" in the select field. - */ -export const allOption: SelectOption = { - value: 'all', - label: 'All', -}; - -/** - * Option type used in the MultiSelectField component. - */ -export type SelectOption = { - /** The value of the option */ - value: string; - /** The label to display for the option */ - label: string; -}; - -/** - * MultiSelectField component props. - */ -export type MultiSelectFieldProps = { - /** Array of options to display in the select dropdown */ - options: SelectOption[]; - /** The default selected value */ - defaultValue?: string[]; - /** Whether to include the "All" option in the dropdown. True by default. */ - hasAllOption?: boolean; - /** Whether the component is in a loading state */ - isLoading?: boolean; - /** Callback function triggered when the selected value changes */ - onChange: (value: string[]) => void; - /** Wether the component show show a "Confirm" button to save changes */ - showConfirmButton?: boolean; -} & Omit, 'onChange'>; - -export const MultiSelectField = ({ - options = [], - defaultValue, - hasAllOption = true, - isLoading = false, - label, - onChange, - fullWidth, - size, - variant = 'outlined', - value, - showConfirmButton, - ...rest -}: MultiSelectFieldProps) => { - const [_value, _setValue] = useState(value || defaultValue || []); - - useEffect(() => { - // Array.isArray(value) && _value !== value && _setValue(value); - Array.isArray(value) && _setValue(value); - }, [value]); - - const handleChange = (event: SelectChangeEvent) => { - const { - target: { value }, - } = event; - // On autofill we get a stringified value. - let values = typeof value === 'string' ? value.split(',') : value; - - if (values.includes(allOption.value) && hasAllOption) { - values = []; - } - - // TODO handle allOption click - // value === allOption.value ? [] : value; - { - showConfirmButton ? _setValue(values) : onChange(values); - } - }; - - const finalOptions = [...(hasAllOption ? [allOption] : []), ...options]; - - const renderValue = (selected: string[]) => { - if (selected.length === 0 && hasAllOption) return allOption.label; - - let valueString: string = selected - .map( - (selectedItem: string) => - options?.find((item) => item.value === selectedItem)?.label, - ) - .join(', '); - - return valueString; - }; - - return ( - - - - {label} - - - - - ); -}; diff --git a/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx b/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx new file mode 100644 index 00000000..c5e48cfa --- /dev/null +++ b/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react'; +import { MultiSelectField } from '../../components/MultiSelectField'; + +import { + enumOptionsIndexForValue, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from '@rjsf/utils'; + +function CustomMultiSelectWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: WidgetProps) { + const { + id, + options, + label, + required, + disabled, + readonly, + value, + onChange, + rawErrors = [], + } = props; + const { enumOptions, enumDisabled, displayVariant, placeholder } = options; + + const _onChange = (value: string[]) => onChange(value); + + useEffect(() => { + if (value?.length && value.filter((val: string) => val).length === 0) { + onChange([]); + } + }, []); + + const selectOptions = () => + (enumOptions || [])?.map(({ value, label }) => { + const disabled = enumDisabled && enumDisabled.indexOf(value) != -1; + + return { value, label, disabled }; + }); + + const selectedIndexes = enumOptionsIndexForValue(value, enumOptions, true); + + const _value = + !selectedIndexes?.length || typeof value === 'undefined' ? [] : value; + + return ( + 0} + hasAllOption={false} + displayVariant={displayVariant === 'chips' ? 'chips' : 'default'} + labelVariant="rockets" + placeholder={placeholder} + size="small" + sx={{ + marginTop: 0.5, + width: '100%', + }} + /> + ); +} + +export default CustomMultiSelectWidget; From bcba58768224d4ffd3e67d400046a5789743b87c Mon Sep 17 00:00:00 2001 From: Rodrigo Dias Date: Mon, 7 Oct 2024 22:05:45 -0300 Subject: [PATCH 3/4] feat: multiselect - fix names --- .../react-material-ui/src/components/MultiSelect/index.ts | 2 +- packages/react-material-ui/src/index.ts | 5 +---- .../src/styles/CustomWidgets/CustomMultiSelectWidget.tsx | 4 ++-- packages/react-material-ui/src/styles/CustomWidgets/index.ts | 1 + 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react-material-ui/src/components/MultiSelect/index.ts b/packages/react-material-ui/src/components/MultiSelect/index.ts index 9fc45806..a9efde2a 100644 --- a/packages/react-material-ui/src/components/MultiSelect/index.ts +++ b/packages/react-material-ui/src/components/MultiSelect/index.ts @@ -1 +1 @@ -export { MultiSelectField, MultiSelectFieldProps } from './MultiSelectField'; +export { MultiSelect, MultiSelectProps } from './MultiSelect'; diff --git a/packages/react-material-ui/src/index.ts b/packages/react-material-ui/src/index.ts index e8a074c9..1d6b27c2 100644 --- a/packages/react-material-ui/src/index.ts +++ b/packages/react-material-ui/src/index.ts @@ -64,10 +64,7 @@ export { TextField, TextFieldProps } from './components/TextField'; export { default as SearchField } from './components/SearchField'; export { default as AutocompleteField } from './components/AutocompleteField'; export { SelectField, SelectFieldProps } from './components/SelectField'; -export { - MultiSelectField, - MultiSelectFieldProps, -} from './components/MultiSelectField'; +export { MultiSelect, MultiSelectProps } from './components/MultiSelect'; export { default as SimpleForm } from './components/SimpleForm'; export { Filter, diff --git a/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx b/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx index c5e48cfa..b98d23a0 100644 --- a/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx +++ b/packages/react-material-ui/src/styles/CustomWidgets/CustomMultiSelectWidget.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { MultiSelectField } from '../../components/MultiSelectField'; +import { MultiSelect } from '../../components/MultiSelect'; import { enumOptionsIndexForValue, @@ -48,7 +48,7 @@ function CustomMultiSelectWidget< !selectedIndexes?.length || typeof value === 'undefined' ? [] : value; return ( - Date: Tue, 8 Oct 2024 09:46:46 -0300 Subject: [PATCH 4/4] feat: multiselect - filter --- .../src/components/Filter/Filter.tsx | 41 +++++++++++++++++-- .../components/MultiSelect/MultiSelect.tsx | 19 ++++----- .../components/submodules/Filter/index.tsx | 23 +++++++++-- .../src/modules/crud/useCrudRoot.tsx | 2 +- 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/packages/react-material-ui/src/components/Filter/Filter.tsx b/packages/react-material-ui/src/components/Filter/Filter.tsx index c799c090..45246b74 100644 --- a/packages/react-material-ui/src/components/Filter/Filter.tsx +++ b/packages/react-material-ui/src/components/Filter/Filter.tsx @@ -11,6 +11,7 @@ import { SelectOption, allOption, } from '../../components/SelectField/SelectField'; +import { MultiSelect } from '../../components/MultiSelect'; import { SearchFieldProps } from '../../components/SearchField/SearchField'; import { OrderableDropDown, ListItem } from '../OrderableDropDown'; import { DatePickerProps } from '@mui/x-date-pickers'; @@ -19,7 +20,12 @@ import DatePickerField from '../../components/DatePickerField'; /** * Type of filter variants available. */ -export type FilterVariant = 'text' | 'autocomplete' | 'select' | 'date'; +export type FilterVariant = + | 'text' + | 'autocomplete' + | 'select' + | 'multiSelect' + | 'date'; /** * Common properties for all filters. @@ -86,13 +92,27 @@ type SelectFilter = { } & FilterCommon; /** - * Type for filter properties that can be text, date, autocomplete, or select. + * Properties for the multiSelect filter. + */ +type MultiSelectFilter = { + type: 'multiSelect'; + options: SelectOption[]; + multiple?: boolean; + defaultValue?: string[]; + size?: SelectFieldProps['size']; + onChange: (value: string[] | null) => void; + value?: string[] | null; +} & FilterCommon; + +/** + * Type for filter properties that can be text, date, autocomplete, select or MultiSelect. */ export type FilterType = | TextFilter | DateFilter | AutocompleteFilter - | SelectFilter; + | SelectFilter + | MultiSelectFilter; /** * Renders the appropriate component based on the filter type. @@ -151,6 +171,21 @@ const renderComponent = (filter: FilterType) => { /> ); + case 'multiSelect': + return ( + + ); + case 'text': return ( { + const isChips = displayVariant === 'chips'; + const handleChange = (event: SelectChangeEvent) => { const { target: { value }, } = event; // On autofill we get a stringified value. - let values = typeof value === 'string' ? value.split(',') : value; + const values = typeof value === 'string' ? value.split(',') : value; if (values.includes(allOption.value) && hasAllOption) { - values = []; + return onChange([]); } onChange(values); @@ -101,8 +103,7 @@ export const MultiSelect = ({ const finalOptions = [...(hasAllOption ? [allOption] : []), ...options]; const renderValues = (selected?: string[]) => { - const isChips = displayVariant === 'chips'; - const valuesIds: string[] = selected || (value as string[]) || []; + const valuesIds: string[] = (selected as string[]) || value || []; const valueLabels = valuesIds.map( (selectedItem: string) => @@ -129,11 +130,11 @@ export const MultiSelect = ({ }; const renderInputValue = (selected: string[]) => { - if (displayVariant === 'chips') { + if (isChips) { return placeholder || label; } - if (selected.length === 0 && hasAllOption) return allOption.label; + if (selected?.length === 0 && hasAllOption) return allOption.label; return renderValues(selected); }; @@ -166,9 +167,7 @@ export const MultiSelect = ({