From e922843afb200ca2b860528dd2947e0569600c90 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Mon, 16 Jun 2025 16:16:32 +0200 Subject: [PATCH] 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 --- .../ObjectFilterDropdownFilterInput.tsx | 18 ++-- .../ObjectFilterDropdownFilterInputHeader.tsx | 16 ++++ ...lterDropdownInnerSelectOperandDropdown.tsx | 69 +++++++++++++++ .../DropdownMenuHeader/DropdownMenuHeader.tsx | 2 + .../components/DropdownMenuInnerSelect.tsx | 88 +++++++++++++++++++ .../constants/DropdownMenuHotkeyScope.ts | 3 + 6 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInputHeader.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInnerSelect.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/dropdown/constants/DropdownMenuHotkeyScope.ts diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx index a2cf93c49..d97803fc2 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx @@ -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 ( - + + ); } else if (isDateFilter) { return ( - + + + ); } else { return ( - + + + {TEXT_FILTER_TYPES.includes(filterType) && ( )} diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInputHeader.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInputHeader.tsx new file mode 100644 index 000000000..0d9aa2edd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInputHeader.tsx @@ -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 ( + + {fieldMetadataItemUsedInDropdown?.label} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx new file mode 100644 index 000000000..bb126ae2e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx index b8d2c7704..27394a2a9 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader.tsx @@ -22,6 +22,8 @@ const StyledHeader = styled.li` background: ${({ theme, onClick }) => onClick ? theme.background.transparent.light : 'none'}; } + + flex-shrink: 0; `; const StyledChildrenWrapper = styled.span` diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInnerSelect.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInnerSelect.tsx new file mode 100644 index 000000000..37f707a78 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInnerSelect.tsx @@ -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 ( + + {selectedOption.label} + + + } + dropdownComponents={ + + + {options.map((selectOption) => ( + { + onChange(selectOption); + closeDropdown(); + }} + text={selectOption.label} + disabled={selectOption.disabled} + selected={selectOption.value === selectedOption.value} + /> + ))} + + + } + dropdownHotkeyScope={{ + scope: DropdownMenuHotkeyScope.InnerSelect, + customScopes: { + commandMenu: false, + commandMenuOpen: false, + }, + }} + dropdownId={dropdownId} + dropdownOffset={{ + x: 8, + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/constants/DropdownMenuHotkeyScope.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/constants/DropdownMenuHotkeyScope.ts new file mode 100644 index 000000000..67147f0b1 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/constants/DropdownMenuHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum DropdownMenuHotkeyScope { + InnerSelect = 'dropdown-menu-inner-select', +}