Created DropdownMenuInnerSelect and implemented it for filter dropdowns (#12626)

This PR introduces a new generic UI component DropdownMenuInnerSelect,
that improves the UI by allowing to have both a dropdown menu header and
a select in the header.

In this PR we implement it just for filter dropdown components.

Fixes https://github.com/twentyhq/core-team-issues/issues/1001
This commit is contained in:
Lucas Bordeau
2025-06-16 16:16:32 +02:00
committed by GitHub
parent ed1593c089
commit e922843afb
6 changed files with 190 additions and 6 deletions

View File

@ -10,7 +10,8 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect';
import { ObjectFilterDropdownOperandDropdown } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandDropdown';
import { ObjectFilterDropdownFilterInputHeader } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInputHeader';
import { ObjectFilterDropdownInnerSelectOperandDropdown } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown';
import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput';
import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes';
import { DATE_PICKER_DROPDOWN_CONTENT_WIDTH } from '@/object-record/object-filter-dropdown/constants/DatePickerDropdownContentWidth';
@ -41,7 +42,7 @@ export const ObjectFilterDropdownFilterInput = ({
filterDropdownId,
);
const isConfigurable =
const isOperandWithFilterValue =
selectedOperandInDropdown &&
[
ViewFilterOperand.Is,
@ -76,25 +77,30 @@ export const ObjectFilterDropdownFilterInput = ({
);
const isDateFilter = DATE_FILTER_TYPES.includes(filterType);
const isOnlyOperand = !isConfigurable;
const isOnlyOperand = !isOperandWithFilterValue;
if (isOnlyOperand) {
return (
<DropdownContent>
<ObjectFilterDropdownOperandDropdown />
<ObjectFilterDropdownFilterInputHeader />
<ObjectFilterDropdownInnerSelectOperandDropdown />
</DropdownContent>
);
} else if (isDateFilter) {
return (
<DropdownContent widthInPixels={DATE_PICKER_DROPDOWN_CONTENT_WIDTH}>
<ObjectFilterDropdownOperandDropdown />
<ObjectFilterDropdownFilterInputHeader />
<ObjectFilterDropdownInnerSelectOperandDropdown />
<DropdownMenuSeparator />
<ObjectFilterDropdownDateInput />
</DropdownContent>
);
} else {
return (
<DropdownContent>
<ObjectFilterDropdownOperandDropdown />
<ObjectFilterDropdownFilterInputHeader />
<ObjectFilterDropdownInnerSelectOperandDropdown />
<DropdownMenuSeparator />
{TEXT_FILTER_TYPES.includes(filterType) && (
<ObjectFilterDropdownTextInput />
)}

View File

@ -0,0 +1,16 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const ObjectFilterDropdownFilterInputHeader = () => {
const fieldMetadataItemUsedInDropdown = useRecoilComponentValueV2(
fieldMetadataItemUsedInDropdownComponentSelector,
);
return (
<DropdownMenuHeader>
{fieldMetadataItemUsedInDropdown?.label}
</DropdownMenuHeader>
);
};

View File

@ -0,0 +1,69 @@
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { useApplyObjectFilterDropdownOperand } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { DropdownMenuInnerSelect } from '@/ui/layout/dropdown/components/DropdownMenuInnerSelect';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
import { SelectOption } from 'twenty-ui/input';
const OBJECT_FILTER_DROPDOWN_INNER_SELECT_OPERAND_DROPDOWN_ID =
'object-filter-dropdown-inner-select-operand-dropdown';
export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
const selectedOperandInDropdown = useRecoilComponentValueV2(
selectedOperandInDropdownComponentState,
);
const fieldMetadataItemUsedInDropdown = useRecoilComponentValueV2(
fieldMetadataItemUsedInDropdownComponentSelector,
);
const subFieldNameUsedInDropdown = useRecoilComponentValueV2(
subFieldNameUsedInDropdownComponentState,
);
const operandsForFilterType = isDefined(fieldMetadataItemUsedInDropdown)
? getRecordFilterOperands({
filterType: getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
),
subFieldName: subFieldNameUsedInDropdown,
})
: [];
const options = operandsForFilterType.map((operand) => ({
label: getOperandLabel(operand),
value: operand,
})) as SelectOption[];
const selectedOption =
options.find((option) => option.value === selectedOperandInDropdown) ??
options[0];
const { applyObjectFilterDropdownOperand } =
useApplyObjectFilterDropdownOperand();
const handleOperandChange = (newOperandOption: SelectOption) => {
applyObjectFilterDropdownOperand(
newOperandOption.value as RecordFilterOperand,
);
};
if (!isDefined(selectedOperandInDropdown)) {
return null;
}
return (
<DropdownMenuInnerSelect
dropdownId={OBJECT_FILTER_DROPDOWN_INNER_SELECT_OPERAND_DROPDOWN_ID}
selectedOption={selectedOption}
onChange={handleOperandChange}
options={options}
/>
);
};

View File

@ -22,6 +22,8 @@ const StyledHeader = styled.li`
background: ${({ theme, onClick }) =>
onClick ? theme.background.transparent.light : 'none'};
}
flex-shrink: 0;
`;
const StyledChildrenWrapper = styled.span`

View File

@ -0,0 +1,88 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownMenuHotkeyScope';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
import { MenuItemSelect } from 'twenty-ui/navigation';
const StyledDropdownMenuInnerSelectDropdownButton = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
height: ${({ theme }) => theme.spacing(7)};
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
width: 100%;
box-sizing: border-box;
cursor: pointer;
`;
export type DropdownMenuInnerSelectProps = {
selectedOption: SelectOption;
onChange: (value: SelectOption) => void;
options: SelectOption[];
dropdownId: string;
};
export const DropdownMenuInnerSelect = ({
selectedOption,
onChange,
options,
dropdownId,
}: DropdownMenuInnerSelectProps) => {
const theme = useTheme();
const { closeDropdown } = useDropdown(dropdownId);
return (
<Dropdown
clickableComponent={
<StyledDropdownMenuInnerSelectDropdownButton>
<span>{selectedOption.label}</span>
<IconChevronDown size={theme.icon.size.sm} />
</StyledDropdownMenuInnerSelectDropdownButton>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{options.map((selectOption) => (
<MenuItemSelect
key={`dropdown-menu-inner-select-item-${selectOption.value}`}
onClick={() => {
onChange(selectOption);
closeDropdown();
}}
text={selectOption.label}
disabled={selectOption.disabled}
selected={selectOption.value === selectedOption.value}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownHotkeyScope={{
scope: DropdownMenuHotkeyScope.InnerSelect,
customScopes: {
commandMenu: false,
commandMenuOpen: false,
},
}}
dropdownId={dropdownId}
dropdownOffset={{
x: 8,
}}
/>
);
};

View File

@ -0,0 +1,3 @@
export enum DropdownMenuHotkeyScope {
InnerSelect = 'dropdown-menu-inner-select',
}