Add empty state to multi select input (#13029)
Took inspiration from `RecordPickerNoRecordFoundMenuItem`. ## Before https://github.com/user-attachments/assets/a8056418-d225-4cd1-b3b8-d7a9792c4f92 ## After https://github.com/user-attachments/assets/126681cd-def4-48d7-a93e-99674993c90e
This commit is contained in:
committed by
GitHub
parent
50ab46cf2a
commit
bc94d58af7
@ -15,9 +15,10 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItemMultiSelectTag } from 'twenty-ui/navigation';
|
||||
import { MenuItem, MenuItemMultiSelectTag } from 'twenty-ui/navigation';
|
||||
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||
|
||||
type MultiSelectInputProps = {
|
||||
@ -39,6 +40,8 @@ export const MultiSelectInput = ({
|
||||
onOptionSelected,
|
||||
dropdownWidth,
|
||||
}: MultiSelectInputProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { resetSelectedItem } = useSelectableList(
|
||||
selectableListComponentInstanceId,
|
||||
);
|
||||
@ -124,29 +127,33 @@ export const MultiSelectInput = ({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{filteredOptionsInDropDown.map((option) => {
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={option.value}
|
||||
itemId={option.value}
|
||||
onEnter={() => {
|
||||
onOptionSelected(formatNewSelectedOptions(option.value));
|
||||
}}
|
||||
>
|
||||
<MenuItemMultiSelectTag
|
||||
{filteredOptionsInDropDown.length === 0 ? (
|
||||
<MenuItem disabled text={t`No option found`} accent="placeholder" />
|
||||
) : (
|
||||
filteredOptionsInDropDown.map((option) => {
|
||||
return (
|
||||
<SelectableListItem
|
||||
key={option.value}
|
||||
selected={values?.includes(option.value) || false}
|
||||
text={option.label}
|
||||
color={option.color ?? 'transparent'}
|
||||
Icon={option.Icon ?? undefined}
|
||||
onClick={() =>
|
||||
onOptionSelected(formatNewSelectedOptions(option.value))
|
||||
}
|
||||
isKeySelected={selectedItemId === option.value}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
})}
|
||||
itemId={option.value}
|
||||
onEnter={() => {
|
||||
onOptionSelected(formatNewSelectedOptions(option.value));
|
||||
}}
|
||||
>
|
||||
<MenuItemMultiSelectTag
|
||||
key={option.value}
|
||||
selected={values?.includes(option.value) || false}
|
||||
text={option.label}
|
||||
color={option.color ?? 'transparent'}
|
||||
Icon={option.Icon ?? undefined}
|
||||
onClick={() =>
|
||||
onOptionSelected(formatNewSelectedOptions(option.value))
|
||||
}
|
||||
isKeySelected={selectedItemId === option.value}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</SelectableList>
|
||||
|
||||
@ -0,0 +1,347 @@
|
||||
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { getFieldInputInstanceId } from '@/object-record/record-field/utils/getFieldInputInstanceId';
|
||||
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
|
||||
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
IconBolt,
|
||||
IconBrandGoogle,
|
||||
IconBrandLinkedin,
|
||||
IconCheck,
|
||||
IconHeart,
|
||||
IconRocket,
|
||||
IconTag,
|
||||
IconTarget,
|
||||
} from 'twenty-ui/display';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { MultiSelectInput } from '../MultiSelectInput';
|
||||
|
||||
type RenderProps = {
|
||||
values: FieldMultiSelectValue;
|
||||
options: SelectOption[];
|
||||
onOptionSelected: (value: FieldMultiSelectValue) => void;
|
||||
onCancel?: () => void;
|
||||
dropdownWidth?: number;
|
||||
};
|
||||
|
||||
const sampleOptions: SelectOption[] = [
|
||||
{
|
||||
value: 'social-media',
|
||||
label: 'Social Media',
|
||||
color: 'blue',
|
||||
Icon: IconTag,
|
||||
},
|
||||
{
|
||||
value: 'search-engine',
|
||||
label: 'Search Engine',
|
||||
color: 'green',
|
||||
Icon: IconBrandGoogle,
|
||||
},
|
||||
{
|
||||
value: 'professional',
|
||||
label: 'Professional Network',
|
||||
color: 'purple',
|
||||
Icon: IconBrandLinkedin,
|
||||
},
|
||||
{ value: 'referral', label: 'Referral', color: 'orange', Icon: IconTag },
|
||||
{
|
||||
value: 'advertising',
|
||||
label: 'Advertising',
|
||||
color: 'red',
|
||||
Icon: IconTarget,
|
||||
},
|
||||
{
|
||||
value: 'content',
|
||||
label: 'Content Marketing',
|
||||
color: 'yellow',
|
||||
Icon: IconCheck,
|
||||
},
|
||||
{ value: 'email', label: 'Email Campaign', color: 'pink', Icon: IconHeart },
|
||||
{
|
||||
value: 'viral',
|
||||
label: 'Viral Marketing',
|
||||
color: 'turquoise',
|
||||
Icon: IconBolt,
|
||||
},
|
||||
{ value: 'growth', label: 'Growth Hacking', color: 'gray', Icon: IconRocket },
|
||||
];
|
||||
|
||||
const priorityOptions: SelectOption[] = [
|
||||
{ value: 'low', label: 'Low Priority', color: 'green' },
|
||||
{ value: 'medium', label: 'Medium Priority', color: 'yellow' },
|
||||
{ value: 'high', label: 'High Priority', color: 'orange' },
|
||||
{ value: 'urgent', label: 'Urgent', color: 'red' },
|
||||
];
|
||||
|
||||
const Render = ({
|
||||
values,
|
||||
options,
|
||||
onOptionSelected,
|
||||
onCancel,
|
||||
dropdownWidth,
|
||||
}: RenderProps) => {
|
||||
const [currentValues, setCurrentValues] =
|
||||
useState<FieldMultiSelectValue>(values);
|
||||
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
|
||||
useEffect(() => {
|
||||
pushFocusItemToFocusStack({
|
||||
focusId: TableHotkeyScope.CellEditMode,
|
||||
component: {
|
||||
type: FocusComponentType.DROPDOWN,
|
||||
instanceId: getFieldInputInstanceId({
|
||||
recordId: '123',
|
||||
fieldName: 'Relation',
|
||||
}),
|
||||
},
|
||||
hotkeyScope: {
|
||||
scope: TableHotkeyScope.CellEditMode,
|
||||
},
|
||||
memoizeKey: getFieldInputInstanceId({
|
||||
recordId: '123',
|
||||
fieldName: 'Relation',
|
||||
}),
|
||||
});
|
||||
}, [pushFocusItemToFocusStack]);
|
||||
|
||||
const handleOptionSelected = (newValues: FieldMultiSelectValue) => {
|
||||
setCurrentValues(newValues);
|
||||
onOptionSelected(newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '400px', padding: '20px' }}>
|
||||
<MultiSelectInput
|
||||
selectableListComponentInstanceId="multi-select-story"
|
||||
values={currentValues}
|
||||
options={options}
|
||||
focusId={DEFAULT_CELL_SCOPE.scope}
|
||||
onCancel={onCancel}
|
||||
onOptionSelected={handleOptionSelected}
|
||||
dropdownWidth={dropdownWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof MultiSelectInput> = {
|
||||
title: 'UI/Field/Input/MultiSelectInput',
|
||||
component: MultiSelectInput,
|
||||
decorators: [ComponentDecorator, I18nFrontDecorator],
|
||||
args: {
|
||||
values: [],
|
||||
options: sampleOptions,
|
||||
onOptionSelected: fn(),
|
||||
},
|
||||
render: Render,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MultiSelectInput>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
values: [],
|
||||
options: sampleOptions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByRole('textbox')).toBeVisible();
|
||||
});
|
||||
|
||||
for (const option of sampleOptions) {
|
||||
expect(canvas.getByText(option.label)).toBeVisible();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
args: {
|
||||
values: ['social-media', 'search-engine'],
|
||||
options: sampleOptions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByRole('textbox')).toBeVisible();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const checkboxes = canvas.getAllByRole('checkbox', { checked: true });
|
||||
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
for (const option of sampleOptions) {
|
||||
expect(canvas.getByText(option.label)).toBeVisible();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleSelection: Story = {
|
||||
args: {
|
||||
values: ['professional'],
|
||||
options: sampleOptions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByRole('textbox')).toBeVisible();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const checkboxes = canvas.getAllByRole('checkbox', { checked: true });
|
||||
|
||||
expect(checkboxes).toHaveLength(1);
|
||||
});
|
||||
|
||||
for (const option of sampleOptions) {
|
||||
expect(canvas.getByText(option.label)).toBeVisible();
|
||||
}
|
||||
|
||||
await userEvent.click(canvas.getByText('Professional Network'));
|
||||
|
||||
await waitFor(() => {
|
||||
const checkboxes = canvas.queryAllByRole('checkbox', { checked: true });
|
||||
|
||||
expect(checkboxes).toHaveLength(0);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyOptions: Story = {
|
||||
args: {
|
||||
values: [],
|
||||
options: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByRole('textbox')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(canvas.getByText('No option found')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const LongLabels: Story = {
|
||||
args: {
|
||||
values: ['long-option-1'],
|
||||
options: [
|
||||
{
|
||||
value: 'long-option-1',
|
||||
label:
|
||||
'This is a very long option label that might overflow the container',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
value: 'long-option-2',
|
||||
label:
|
||||
'Another extremely long option label to test text wrapping behavior',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'short',
|
||||
label: 'Short',
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByRole('textbox')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(
|
||||
canvas.getByText(
|
||||
'This is a very long option label that might overflow the container',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(canvas.getByText('Short')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchFiltering: Story = {
|
||||
args: {
|
||||
values: [],
|
||||
options: sampleOptions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const searchInput = canvas.getByRole('textbox');
|
||||
|
||||
await userEvent.type(searchInput, 'marketing');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText('Content Marketing')).toBeVisible();
|
||||
expect(canvas.getByText('Viral Marketing')).toBeVisible();
|
||||
});
|
||||
|
||||
expect(canvas.queryByText('Social Media')).not.toBeInTheDocument();
|
||||
expect(canvas.getAllByRole('checkbox')).toHaveLength(2);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoResultsFound: Story = {
|
||||
args: {
|
||||
values: [],
|
||||
options: sampleOptions,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const searchInput = canvas.getByRole('textbox');
|
||||
|
||||
await userEvent.type(searchInput, 'xyz123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText('No option found')).toBeVisible();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const KeyboardNavigation: Story = {
|
||||
args: {
|
||||
values: [],
|
||||
options: priorityOptions,
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const searchInput = await canvas.findByRole('textbox');
|
||||
|
||||
await userEvent.click(searchInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchInput).toHaveFocus();
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
|
||||
const secondOption = await canvas.findByText('Medium Priority');
|
||||
expect(secondOption).toBeVisible();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onOptionSelected).toHaveBeenCalledWith(['medium']);
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user