Skip to content

Commit

Permalink
Multiselct with groups or checkboxes (#6672)
Browse files Browse the repository at this point in the history
* Multiselct with groups or checkboxes

* fixed some types

* updated specs

* removed unused vars
  • Loading branch information
konzz authored Apr 15, 2024
1 parent ebbc215 commit 8d8c9bd
Show file tree
Hide file tree
Showing 10 changed files with 417 additions and 147 deletions.
4 changes: 4 additions & 0 deletions app/react/App/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2978,6 +2978,10 @@ input:checked + .toggle-bg {
border-radius: 4px;
}

.rounded-\[6px\] {
border-radius: 6px;
}

.rounded-full {
border-radius: 9999px;
}
Expand Down
43 changes: 21 additions & 22 deletions app/react/V2/Components/Forms/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable react/require-default-props */
import React, { ReactEventHandler, Ref } from 'react';
import { Checkbox as FlowbiteCheckbox, Label } from 'flowbite-react';
import { isString } from 'lodash';
Expand All @@ -8,7 +9,7 @@ interface CheckboxProps {
onChange?: ReactEventHandler<HTMLInputElement>;
checked?: boolean;
defaultChecked?: boolean;
label: string;
label: string | React.ReactNode;
className?: string;
disabled?: boolean;
}
Expand All @@ -18,27 +19,25 @@ const Checkbox = React.forwardRef(
{ name, onChange, className, disabled, checked, label, defaultChecked }: CheckboxProps,
ref: Ref<any>
) => (
<div className="tw-content">
<fieldset className={`flex flex-wrap gap-4 ${className}`} id={`radio_${name}`}>
<div className="flex items-center gap-2 mr-4">
<FlowbiteCheckbox
checked={checked}
id={name}
name={name}
disabled={disabled || false}
defaultChecked={defaultChecked || false}
onChange={onChange}
ref={ref}
/>
<Label
htmlFor={name}
className={`text-sm font-medium text-gray-900 ${disabled ? '!text-gray-300' : ''}`}
>
{isString(label) ? <Translate>{label}</Translate> : label}
</Label>
</div>
</fieldset>
</div>
<fieldset className={`flex flex-wrap gap-4 ${className}`} id={`radio_${name}`}>
<div className="flex items-center gap-2 mr-4">
<FlowbiteCheckbox
checked={checked}
id={name}
name={name}
disabled={disabled || false}
defaultChecked={defaultChecked || false}
onChange={onChange}
ref={ref}
/>
<Label
htmlFor={name}
className={`text-sm font-medium text-gray-900 cursor-pointer ${disabled ? '!text-gray-300' : ''}`}
>
{isString(label) ? <Translate>{label}</Translate> : label}
</Label>
</div>
</fieldset>
)
);

Expand Down
246 changes: 246 additions & 0 deletions app/react/V2/Components/Forms/MultiselectList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable max-statements */
/* eslint-disable react/no-multi-comp */
import React, { useEffect, useState } from 'react';
import { Translate } from 'app/I18N';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import { InputField, RadioSelect } from '.';
import { Pill } from '../UI/Pill';
import { Label } from './Label';
import { Checkbox } from './Checkbox';

interface Option {
label: string | React.ReactNode;
searchLabel: string;
value: string;
items?: Option[];
}

interface MultiselectListProps {
items: Option[];
onChange: (selectedItems: string[]) => void;
label?: string | React.ReactNode;
hasErrors?: boolean;
className?: string;
checkboxes?: boolean;
value?: string[];
foldableGroups?: boolean;
}

const SelectedCounter = ({ selectedItems }: { selectedItems: string[] }) => (
<>
<Translate>Selected</Translate> {selectedItems.length ? `(${selectedItems.length})` : ''}
</>
);

const MultiselectList = ({
items,
onChange,
className,
label,
hasErrors,
value = [],
checkboxes = false,
foldableGroups = false,
}: MultiselectListProps) => {
const [selectedItems, setSelectedItems] = useState<string[]>(value);
const [showAll, setShowAll] = useState<boolean>(true);
const [searchTerm, setSearchTerm] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [openGroups, setOpenGroups] = useState<string[]>([]);

useEffect(() => {
if (onChange) onChange(selectedItems);
}, [onChange, selectedItems]);

useEffect(() => {
let filtered = [...items];
if (!showAll) {
filtered = filtered
.filter(item => {
const itemiSelected = selectedItems.includes(item.value);
const containsSelected = item.items?.some(childItem =>
selectedItems.includes(childItem.value)
);

return itemiSelected || containsSelected;
})
.map(item => {
if (item.items) {
return {
...item,
items: item.items.filter(childItem => selectedItems.includes(childItem.value)),
};
}
return item;
});
}

if (!searchTerm) {
setFilteredItems(filtered);
return;
}

filtered = filtered
.filter(({ searchLabel }) => searchLabel.toLowerCase().includes(searchTerm.toLowerCase()))
.map(item => {
if (item.items) {
return {
...item,
items: item.items.filter(({ searchLabel }) =>
searchLabel.toLowerCase().includes(searchTerm.toLowerCase())
),
};
}
return item;
});

setFilteredItems(filtered);
}, [items, searchTerm, showAll, selectedItems]);

const handleSelect = (_value: string) => {
if (selectedItems.includes(_value)) {
setSelectedItems(selectedItems.filter(item => item !== _value));
} else {
setSelectedItems([...selectedItems, _value]);
}
};

const applyFilter = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
setShowAll(target.value === 'true');
};

const renderButtonItem = (item: Option) => {
if (item.items) {
return renderGroup(item);
}

const selected = selectedItems.includes(item.value);
const borderSyles = selected
? 'border-sucess-200'
: 'border-transparent hover:border-primary-300';

return (
<li key={item.value} className="mb-4">
<button
type="button"
className={`w-full flex text-left p-2.5 border ${borderSyles} rounded-lg items-center`}
onClick={() => handleSelect(item.value)}
>
<span className="flex-1">{item.label}</span>
<div className="flex-1">
<Pill className="float-right" color={selected ? 'green' : 'primary'}>
{selected ? <Translate>Selected</Translate> : <Translate>Select</Translate>}
</Pill>
</div>
</button>
</li>
);
};

const renderCheckboxItem = (item: Option) => {
if (item.items) {
return renderGroup(item);
}
const selected = selectedItems.includes(item.value);
return (
<li key={item.value} className="mb-2">
<Checkbox
name={item.value}
label={item.label}
checked={selected}
onChange={() => handleSelect(item.value)}
/>
</li>
);
};

const handleGroupToggle = (groupKey: string) => {
if (openGroups.includes(groupKey)) {
setOpenGroups(openGroups.filter(group => group !== groupKey));
} else {
setOpenGroups([...openGroups, groupKey]);
}
};

const isGroupOpen = (groupKey: string) => openGroups.includes(groupKey);

const renderItem = (item: Option) =>
checkboxes ? renderCheckboxItem(item) : renderButtonItem(item);

const renderGroup = (group: Option) => {
const isOpen = isGroupOpen(group.value);
if (foldableGroups) {
return (
<li key={group.value} className="mb-4">
<div
className={`flex justify-between p-3 mb-4 rounded-lg ${isOpen ? 'bg-indigo-50' : 'bg-gray-50'}`}
onClick={() => handleGroupToggle(group.value)}
>
<span className="block text-sm font-bold text-gray-900">{group.label}</span>
<button
className="text-indigo-800 bg-indigo-200 rounded-[6px] text-xs font-medium px-1.5 py-0.5 flex flex-row items-center justify-center gap-1"
type="button"
>
<div className="w-3 h-3 text-sm">
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</div>
<Translate>Properties</Translate>
</button>
</div>
{isOpen && <ul className="pl-4">{group.items?.map(renderItem)}</ul>}
</li>
);
}

return (
<li key={group.value} className="mb-4">
<span className="block mb-4 text-sm font-bold text-gray-900">{group.label}</span>
<ul className="">{group.items?.map(renderItem)}</ul>
</li>
);
};

return (
<div className={`flex flex-col relative ${className}`}>
<div className="sticky top-0 w-full px-2 mb-4">
<Label htmlFor="search-multiselect" hideLabel={!label} hasErrors={Boolean(hasErrors)}>
{label}
</Label>
<InputField
id="search-multiselect"
label="search-multiselect"
hideLabel
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search"
value={searchTerm}
clearFieldAction={() => setSearchTerm('')}
/>
<RadioSelect
name="filter"
orientation="horizontal"
options={[
{
label: <Translate>All</Translate>,
value: 'true',
defaultChecked: true,
},
{
label: <SelectedCounter selectedItems={selectedItems} />,
value: 'false',
disabled: selectedItems.length === 0,
},
]}
onChange={applyFilter}
className="px-1 pt-4"
/>
</div>

<ul className="px-2 w-full overflow-y-scroll max-h-[calc(100vh_-_9rem)]">
{filteredItems.map(renderItem)}
</ul>
</div>
);
};

export { MultiselectList };
2 changes: 1 addition & 1 deletion app/react/V2/Components/Forms/RadioSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const RadioSelect = ({
/>
<Label
htmlFor={`${name}_${option.value}`}
className={option.disabled ? '!text-gray-300' : ''}
className={`cursor-pointer ${option.disabled ? '!text-gray-300' : ''}`}
>
{isString(option.label) ? <Translate>{option.label}</Translate> : option.label}
</Label>
Expand Down
1 change: 1 addition & 0 deletions app/react/V2/Components/Forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export type { SelectProps, OptionSchema } from './Select';
export { Checkbox } from './Checkbox';
export { EnableButtonCheckbox } from './EnableButtonCheckbox';
export { DatePicker, DateRangePicker } from './DatePicker/DatePicker';
export { MultiselectList } from './MultiselectList';
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@ import 'cypress-axe';
import { Provider } from 'react-redux';
import { mount } from '@cypress/react18';
import { LEGACY_createStore as createStore } from 'V2/shared/testingHelpers';
import { SearchMultiselect } from '../SearchMultiSelect';
import { MultiselectList } from '../MultiselectList';

describe('SearchMultiSelect.cy.tsx', () => {
describe('MultiselectList.cy.tsx', () => {
const pizzas = [
{ label: 'Margherita', value: 'MGT' },
{ label: 'Pepperoni', value: 'PPR' },
{ label: 'Hawaiian', value: 'HWN' },
{ label: 'Vegetarian', value: 'VGT' },
{ label: 'Meat Lovers', value: 'MLV' },
{ label: 'BBQ Chicken', value: 'BQC' },
{ label: 'Mushroom', value: 'MSH' },
{ label: 'Four Cheese', value: 'FC' },
{ label: 'Buffalo Chicken', value: 'BFC' },
{ label: 'Chicken Bacon Ranch', value: 'CBR' },
{ label: 'Chicken Alfredo', value: 'CAF' },
{ label: 'Margherita', value: 'MGT', searchLabel: 'Margherita' },
{ label: 'Pepperoni', value: 'PPR', searchLabel: 'Pepperoni' },
{ label: 'Hawaiian', value: 'HWN', searchLabel: 'Hawaiian' },
{ label: 'Vegetarian', value: 'VGT', searchLabel: 'Vegetarian' },
{ label: 'Meat Lovers', value: 'MLV', searchLabel: 'Meat Lovers' },
{ label: 'BBQ Chicken', value: 'BQC', searchLabel: 'BBQ Chicken' },
{ label: 'Mushroom', value: 'MSH', searchLabel: 'Mushroom' },
{ label: 'Four Cheese', value: 'FC', searchLabel: 'Four Cheese' },
{ label: 'Buffalo Chicken', value: 'BFC', searchLabel: 'Buffalo Chicken' },
{ label: 'Chicken Bacon Ranch', value: 'CBR', searchLabel: 'Chicken Bacon Ranch' },
{ label: 'Chicken Alfredo', value: 'CAF', searchLabel: 'Chicken Alfredo' },
];
let selected: string[] = [];

Expand All @@ -26,7 +26,7 @@ describe('SearchMultiSelect.cy.tsx', () => {
mount(
<Provider store={createStore()}>
<div className="p-2 tw-content">
<SearchMultiselect
<MultiselectList
items={pizzas}
onChange={selectedItems => {
selected = selectedItems;
Expand Down Expand Up @@ -54,7 +54,7 @@ describe('SearchMultiSelect.cy.tsx', () => {
cy.contains('Buffalo Chicken').should('be.visible');
cy.contains('Chicken Bacon Ranch').should('be.visible');
cy.contains('Chicken Alfredo').should('be.visible');
cy.contains('Margherita').should('not.be.visible');
cy.contains('Margherita').should('not.exist');
});

it('should select options', () => {
Expand Down
Loading

0 comments on commit 8d8c9bd

Please sign in to comment.