Improved dropdown menu headers for filter and sorts (#13177)

This PR improves dropdown menu headers for filter and sort dropdown in
view bar and editable filter chips.

It adds what's necessary to navigate back or close the dropdown, so that
we don't rely solely on click outside to exit the dropdown.

This PR also refactors the components so that we clearly identify the
two code paths that can use filter dropdowns : view bar and filter chip,
everything that can be DRY stays in the object-filter-dropdown module
but we try to have our wrapping components in each distinct module
instead of blending everything with ternaries inside
object-filter-dropdown module.

The vector search input value wasn't correctly handled across the
different dropdowns, due to a wrong component instance management, since
the dropdown menu header improvement put this into light, I also
refactored the state management of the vector search input.

@Bonapara please check the QA video and tell me if it's ok, I didn't add
dropdown menu header on the advanced filter field list dropdown because
it's a select more than a standalone dropdown, what do you think ?

QA : 


https://github.com/user-attachments/assets/17080f32-f302-436c-937b-3577715b7e84


QA Vector search fix : 



https://github.com/user-attachments/assets/6367bbf6-8a98-4b53-86cf-6ba92be130eb

Fixes https://github.com/twentyhq/core-team-issues/issues/640
Fixes https://github.com/twentyhq/core-team-issues/issues/1206
This commit is contained in:
Lucas Bordeau
2025-07-11 16:47:52 +02:00
committed by GitHub
parent 6285613a25
commit e53e09dfd3
21 changed files with 422 additions and 197 deletions

View File

@ -0,0 +1,38 @@
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
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';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
export const ObjectFilterDropdownContentWrapper = ({
children,
}: React.PropsWithChildren) => {
const fieldMetadataItemUsedInDropdown = useRecoilComponentValueV2(
fieldMetadataItemUsedInDropdownComponentSelector,
);
if (!isDefined(fieldMetadataItemUsedInDropdown)) {
return null;
}
const filterType = getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
);
const isDateFilter = DATE_FILTER_TYPES.includes(filterType);
return (
<DropdownContent
widthInPixels={
isDateFilter
? DATE_PICKER_DROPDOWN_CONTENT_WIDTH
: GenericDropdownContentWidth.ExtraLarge
}
>
{children}
</DropdownContent>
);
};

View File

@ -5,22 +5,19 @@ import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-d
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ViewBarFilterDropdownVectorSearchInput } from '@/views/components/ViewBarFilterDropdownVectorSearchInput';
import { ViewFilterOperand } from 'twenty-shared/src/types/ViewFilterOperand';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect';
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 { ObjectFilterDropdownVectorSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownVectorSearchInput';
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';
import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/NumberFilterTypes';
import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/TextFilterTypes';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
@ -62,11 +59,7 @@ export const ObjectFilterDropdownFilterInput = ({
selectedOperandInDropdown === ViewFilterOperand.VectorSearch;
if (isVectorSearchFilter && isDefined(filterDropdownId)) {
return (
<ViewBarFilterDropdownVectorSearchInput
filterDropdownId={filterDropdownId}
/>
);
return <ObjectFilterDropdownVectorSearchInput />;
}
if (!isDefined(fieldMetadataItemUsedInDropdown)) {
@ -82,24 +75,21 @@ export const ObjectFilterDropdownFilterInput = ({
if (isOnlyOperand) {
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<ObjectFilterDropdownFilterInputHeader />
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
</DropdownContent>
</>
);
} else if (isDateFilter) {
return (
<DropdownContent widthInPixels={DATE_PICKER_DROPDOWN_CONTENT_WIDTH}>
<ObjectFilterDropdownFilterInputHeader />
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
<DropdownMenuSeparator />
<ObjectFilterDropdownDateInput />
</DropdownContent>
</>
);
} else {
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<ObjectFilterDropdownFilterInputHeader />
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
<DropdownMenuSeparator />
{TEXT_FILTER_TYPES.includes(filterType) && (
@ -130,7 +120,7 @@ export const ObjectFilterDropdownFilterInput = ({
</>
)}
{filterType === 'BOOLEAN' && <ObjectFilterDropdownBooleanSelect />}
</DropdownContent>
</>
);
}
};

View File

@ -1,16 +1,56 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewBarFilterDropdownFilterInputMenuHeader } from '@/views/components/ViewBarFilterDropdownFilterInputMenuHeader';
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { ViewFilterOperand } from 'twenty-shared/types';
import { IconX } from 'twenty-ui/display';
export const ObjectFilterDropdownFilterInputHeader = () => {
const { t } = useLingui();
const fieldMetadataItemUsedInDropdown = useRecoilComponentValueV2(
fieldMetadataItemUsedInDropdownComponentSelector,
);
return (
<DropdownMenuHeader>
{fieldMetadataItemUsedInDropdown?.label}
</DropdownMenuHeader>
const selectedOperandInDropdown = useRecoilComponentValueV2(
selectedOperandInDropdownComponentState,
);
const { closeDropdown } = useCloseDropdown();
const dropdownInstanceId = useContext(
DropdownComponentInstanceContext,
)?.instanceId;
const isInViewBarFilterDropdown =
dropdownInstanceId === VIEW_BAR_FILTER_DROPDOWN_ID;
const isVectorSearchFilter =
selectedOperandInDropdown === ViewFilterOperand.VectorSearch;
if (isInViewBarFilterDropdown) {
return <ViewBarFilterDropdownFilterInputMenuHeader />;
} else {
return (
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => closeDropdown(dropdownInstanceId)}
Icon={IconX}
/>
}
>
{isVectorSearchFilter
? t`Search`
: fieldMetadataItemUsedInDropdown?.label}
</DropdownMenuHeader>
);
}
};

View File

@ -1,4 +1,6 @@
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
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';
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';
@ -7,6 +9,7 @@ import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/ge
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
import { SelectOption } from 'twenty-ui/input';
@ -54,16 +57,30 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
);
};
if (!isDefined(selectedOperandInDropdown)) {
if (
!isDefined(selectedOperandInDropdown) ||
!isDefined(fieldMetadataItemUsedInDropdown)
) {
return null;
}
const filterType = getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
);
const isDateFilter = DATE_FILTER_TYPES.includes(filterType);
const widthInPixels = isDateFilter
? DATE_PICKER_DROPDOWN_CONTENT_WIDTH
: GenericDropdownContentWidth.ExtraLarge;
return (
<DropdownMenuInnerSelect
dropdownId={OBJECT_FILTER_DROPDOWN_INNER_SELECT_OPERAND_DROPDOWN_ID}
selectedOption={selectedOption}
onChange={handleOperandChange}
options={options}
widthInPixels={widthInPixels}
/>
);
};

View File

@ -0,0 +1,38 @@
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useVectorSearchFilterActions } from '@/views/hooks/useVectorSearchFilterActions';
import { vectorSearchInputComponentState } from '@/views/states/vectorSearchInputComponentState';
import { useLingui } from '@lingui/react/macro';
import { useDebouncedCallback } from 'use-debounce';
export const ObjectFilterDropdownVectorSearchInput = () => {
const { t } = useLingui();
const [vectorSearchInputValue, setVectorSearchInputValue] =
useRecoilComponentStateV2(vectorSearchInputComponentState);
const { applyVectorSearchFilter } = useVectorSearchFilterActions();
const debouncedApplyVectorSearchFilter = useDebouncedCallback(
(value: string) => {
applyVectorSearchFilter(value);
},
500,
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
setVectorSearchInputValue(inputValue);
debouncedApplyVectorSearchFilter(inputValue);
};
return (
<DropdownMenuSearchInput
autoFocus
type="text"
value={vectorSearchInputValue}
placeholder={t`Search`}
onChange={handleSearchChange}
/>
);
};

View File

@ -1,5 +1,3 @@
import styled from '@emotion/styled';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { OBJECT_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ObjectSortDropdownId';
@ -20,7 +18,10 @@ import { visibleTableColumnsComponentSelector } from '@/object-record/record-tab
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuInnerSelect } from '@/ui/layout/dropdown/components/DropdownMenuInnerSelect';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
@ -32,54 +33,12 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useTheme } from '@emotion/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { IconChevronDown, useIcons } from 'twenty-ui/display';
import { IconX, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { v4 } from 'uuid';
export const StyledInput = styled.input`
background: transparent;
border: none;
border-top: none;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 0;
color: ${({ theme }) => theme.font.color.primary};
margin: 0;
outline: none;
padding: ${({ theme }) => theme.spacing(2)};
height: 19px;
font-family: inherit;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: inherit;
max-width: 100%;
overflow: hidden;
text-decoration: none;
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
}
`;
const StyledSelectedSortDirectionContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
border-radius: ${({ theme }) => theme.border.radius.md};
position: absolute;
top: 32px;
width: 100%;
z-index: 1000;
`;
const StyledDropdownMenuHeaderEndComponent = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
display: flex;
align-items: center;
`;
export const ObjectSortDropdownButton = () => {
const { resetRecordSortDropdownSearchInput } =
useResetRecordSortDropdownSearchInput();
@ -88,10 +47,6 @@ export const ObjectSortDropdownButton = () => {
objectSortDropdownSearchInputComponentState,
);
const isRecordSortDirectionMenuUnfolded = useRecoilComponentValueV2(
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
);
const { resetSortDropdown } = useResetSortDropdown();
const { recordIndexId, objectMetadataItem } = useRecordIndexContextOrThrow();
@ -195,8 +150,6 @@ export const ObjectSortDropdownButton = () => {
const { t } = useLingui();
const theme = useTheme();
const selectableItemIdArray = [
...visibleFieldMetadataItems.map((item) => item.id),
...hiddenFieldMetadataItems.map((item) => item.id),
@ -227,51 +180,50 @@ export const ObjectSortDropdownButton = () => {
}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => closeSortDropdown()}
Icon={IconX}
/>
}
>
{t`Sort`}
</DropdownMenuHeader>
<DropdownMenuInnerSelect
dropdownId="record-sort-direction-dropdown"
options={RECORD_SORT_DIRECTIONS.map((sortDirection) => ({
value: sortDirection,
label: sortDirection === 'asc' ? t`Ascending` : t`Descending`,
}))}
selectedOption={{
value: selectedRecordSortDirection,
label:
selectedRecordSortDirection === 'asc'
? t`Ascending`
: t`Descending`,
}}
onChange={(sortDirection) =>
handleSortDirectionClick(
sortDirection.value as RecordSortDirection,
)
}
widthInPixels={GenericDropdownContentWidth.ExtraLarge}
/>
<DropdownMenuSeparator />
<DropdownMenuSearchInput
autoFocus
value={objectSortDropdownSearchInput}
placeholder={t`Search fields`}
onChange={(event) =>
setObjectSortDropdownSearchInput(event.target.value)
}
/>
<SelectableList
selectableListInstanceId={OBJECT_SORT_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
focusId={OBJECT_SORT_DROPDOWN_ID}
>
{isRecordSortDirectionMenuUnfolded && (
<StyledSelectedSortDirectionContainer>
<DropdownMenuItemsContainer>
{RECORD_SORT_DIRECTIONS.map((sortDirection, index) => (
<MenuItem
key={index}
focused={selectedItemId === sortDirection}
onClick={() => handleSortDirectionClick(sortDirection)}
text={
sortDirection === 'asc' ? t`Ascending` : t`Descending`
}
/>
))}
</DropdownMenuItemsContainer>
</StyledSelectedSortDirectionContainer>
)}
<DropdownMenuHeader
onClick={() =>
setIsRecordSortDirectionMenuUnfolded(
!isRecordSortDirectionMenuUnfolded,
)
}
EndComponent={
<StyledDropdownMenuHeaderEndComponent>
<IconChevronDown size={theme.icon.size.md} />
</StyledDropdownMenuHeaderEndComponent>
}
>
{selectedRecordSortDirection === 'asc'
? t`Ascending`
: t`Descending`}
</DropdownMenuHeader>
<StyledInput
autoFocus
value={objectSortDropdownSearchInput}
placeholder={t`Search fields`}
onChange={(event) =>
setObjectSortDropdownSearchInput(event.target.value)
}
/>
{shouldShowVisibleFields && (
<>
<DropdownMenuSectionLabel label={t`Visible fields`} />