Implement sub-field filtering on CURRENCY field type (#11726)

This PR implements sub-field filtering on CURRENCY field type and
improves many related zones.

- Created a ObjectFilterDropdownCurrencySelect dropdown component for
filtering on multiple currencies
- Added currencyCode sub-field to CurrencyFilter type
- Created getDefaultSubFieldNameForCompositeFilterableFieldType to avoid
situation where we don't have any sub field name in sub field filtering
situations.
- Implemented filtering for currencyCode in
computeFilterRecordGqlOperationFilter
- Implemented CURRENCY type in getRecordFilterOperands
- Implemented isMatchingCurrencyFilter for using in
isRecordMatchingFilter for proper optimistic rendering
- Created turnCurrencyIntoSelectableItem to help
ObjectFilterDropdownCurrencySelect

Testing : 
- Added test for currency sub fields in getOperandsForFilterType
- Completely reworked isMatchingCurrencyFilter test

Improvements : 
- Created a unique CURRENCIES constant to avoid re-creating it at
various places
- Derive the type FilterableFieldType from a constant array
FILTERABLE_FIELD_TYPES, so it's easier to work with
- Added areCompositeTypeSubFieldsFilterable
- Fixed a bug with empty value '[]' that was preventing the auto-removal
of a filter chip

Miscellaneous : 
- Created isExpectedSubFieldName util to do a type-safe check of a
subFieldName
- Better naming : renamed isCompositeField to isCompositeFieldType
- Created isCompositeTypeFilterableWithAny to specify which field types
are filterable by any sub field
- Better naming : renamed
ObjectFilterDropdownFilterSelectCompositeFieldSubMenu to
ObjectFilterDropdownSubFieldSelect
- Better naming : renamed ObjectFilterDropdownFilterSelect to
ObjectFilterDropdownFieldSelect
- Created isEmptinessOperand util instead of duplicating the same
hard-coded check in multiple places
- Better naming : used subFieldName instead of compositeFieldName for
consistency
- UseEffect removal : removed unnecessary useEffect in
MultipleSelectDropdown

Fixes a bug where Empty and Not weren't appearing in filter chip in
particular cases
Fixes https://github.com/twentyhq/core-team-issues/issues/498
Fixes https://github.com/twentyhq/twenty/issues/7558
This commit is contained in:
Lucas Bordeau
2025-04-25 19:33:00 +02:00
committed by GitHub
parent f201091c68
commit 50cb32d122
51 changed files with 1358 additions and 422 deletions

View File

@ -7,12 +7,15 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { AdvancedFilterDropdownDateInput } from '@/object-record/advanced-filter/components/AdvancedFilterDropdownDateInput'; import { AdvancedFilterDropdownDateInput } from '@/object-record/advanced-filter/components/AdvancedFilterDropdownDateInput';
import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect'; import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect';
import { ObjectFilterDropdownCurrencySelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect';
import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput'; import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput';
import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes'; import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { isExpectedSubFieldName } from '@/object-record/object-filter-dropdown/utils/isExpectedSubFieldName';
import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField'; import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { FieldMetadataType } from 'twenty-shared/types';
type AdvancedFilterDropdownFilterInputProps = { type AdvancedFilterDropdownFilterInputProps = {
filterDropdownId?: string; filterDropdownId?: string;
@ -65,6 +68,18 @@ export const AdvancedFilterDropdownFilterInput = ({
</> </>
)} )}
{filterType === 'BOOLEAN' && <ObjectFilterDropdownBooleanSelect />} {filterType === 'BOOLEAN' && <ObjectFilterDropdownBooleanSelect />}
{filterType === 'CURRENCY' &&
(isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'currencyCode',
recordFilter.subFieldName,
) ? (
<>
<ObjectFilterDropdownCurrencySelect dropdownWidth={280} />
</>
) : (
<></>
))}
</> </>
); );
}; };

View File

@ -1,6 +1,6 @@
import { useGetFieldMetadataItemById } from '@/object-metadata/hooks/useGetFieldMetadataItemById'; import { useGetFieldMetadataItemById } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { isValidSubFieldName } from '@/settings/data-model/utils/isValidSubFieldName'; import { isValidSubFieldName } from '@/settings/data-model/utils/isValidSubFieldName';
import { SelectControl } from '@/ui/input/components/SelectControl'; import { SelectControl } from '@/ui/input/components/SelectControl';
@ -38,7 +38,7 @@ export const AdvancedFilterFieldSelectDropdownButtonClickableSelect = ({
const subFieldLabel = const subFieldLabel =
isDefined(fieldMetadataItem) && isDefined(fieldMetadataItem) &&
isCompositeField(fieldMetadataItem.type) && isCompositeFieldType(fieldMetadataItem.type) &&
isNonEmptyString(recordFilter?.subFieldName) && isNonEmptyString(recordFilter?.subFieldName) &&
isValidSubFieldName(recordFilter.subFieldName) isValidSubFieldName(recordFilter.subFieldName)
? getCompositeSubFieldLabel( ? getCompositeSubFieldLabel(

View File

@ -19,7 +19,7 @@ import { ObjectFilterDropdownFilterSelectMenuItemV2 } from '@/object-record/obje
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState'; import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext'; import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
@ -103,7 +103,7 @@ export const AdvancedFilterFieldSelectMenu = ({
selectedFieldMetadataItem.type, selectedFieldMetadataItem.type,
); );
if (isCompositeField(filterType)) { if (isCompositeFieldType(filterType)) {
setObjectFilterDropdownSubMenuFieldType(filterType); setObjectFilterDropdownSubMenuFieldType(filterType);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id); setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);

View File

@ -11,7 +11,9 @@ import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/o
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { getFilterableFieldTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel'; import { getFilterableFieldTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel';
import { isCompositeFieldTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/isCompositeFieldTypeFilterable'; import { ICON_NAME_BY_SUB_FIELD } from '@/object-record/record-filter/constants/IconNameBySubField';
import { areCompositeTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable';
import { isCompositeTypeFilterableByAnySubField } from '@/object-record/record-filter/utils/isCompositeTypeFilterableByAnySubField';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
@ -87,19 +89,23 @@ export const AdvancedFilterSubFieldSelectMenu = ({
return null; return null;
} }
const options = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[ const subFieldNames = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[
objectFilterDropdownSubMenuFieldType objectFilterDropdownSubMenuFieldType
].filterableSubFields.sort((a, b) => a.localeCompare(b)); ].filterableSubFields.sort((a, b) => a.localeCompare(b));
const subFieldsAreFilterable = const subFieldsAreFilterable =
isDefined(fieldMetadataItemUsedInDropdown) && isDefined(fieldMetadataItemUsedInDropdown) &&
isCompositeFieldTypeSubFieldsFilterable( areCompositeTypeSubFieldsFilterable(fieldMetadataItemUsedInDropdown.type);
const compositeFieldTypeIsFilterableByAnySubField =
isDefined(fieldMetadataItemUsedInDropdown) &&
isCompositeTypeFilterableByAnySubField(
fieldMetadataItemUsedInDropdown.type, fieldMetadataItemUsedInDropdown.type,
); );
const selectableItemIdArray = [ const selectableItemIdArray = [
'-1', '-1',
...options.map((subFieldName) => subFieldName), ...subFieldNames.map((subFieldName) => subFieldName),
]; ];
return ( return (
@ -120,24 +126,28 @@ export const AdvancedFilterSubFieldSelectMenu = ({
selectableItemIdArray={selectableItemIdArray} selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId} selectableListInstanceId={advancedFilterFieldSelectDropdownId}
> >
<SelectableListItem {compositeFieldTypeIsFilterableByAnySubField && (
itemId={'-1'} <SelectableListItem
key={`select-filter-${-1}`} itemId={'-1'}
onEnter={() => { key={`select-filter-${-1}`}
handleSelectFilter(fieldMetadataItemUsedInDropdown); onEnter={() => {
}}
>
<MenuItem
focused={selectedItemId === '-1'}
onClick={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown); handleSelectFilter(fieldMetadataItemUsedInDropdown);
}} }}
LeftIcon={IconApps} >
text={`Any ${getFilterableFieldTypeLabel(objectFilterDropdownSubMenuFieldType)} field`} <MenuItem
/> key={`select-filter-${-1}`}
</SelectableListItem> testId={`select-filter-${-1}`}
focused={selectedItemId === '-1'}
onClick={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown);
}}
LeftIcon={IconApps}
text={`Any ${getFilterableFieldTypeLabel(objectFilterDropdownSubMenuFieldType)} field`}
/>
</SelectableListItem>
)}
{subFieldsAreFilterable && {subFieldsAreFilterable &&
options.map((subFieldName, index) => ( subFieldNames.map((subFieldName, index) => (
<SelectableListItem <SelectableListItem
itemId={subFieldName} itemId={subFieldName}
key={`select-filter-${index}`} key={`select-filter-${index}`}
@ -153,16 +163,21 @@ export const AdvancedFilterSubFieldSelectMenu = ({
key={`select-filter-${index}`} key={`select-filter-${index}`}
testId={`select-filter-${index}`} testId={`select-filter-${index}`}
onClick={() => { onClick={() => {
handleSelectFilter( if (isDefined(fieldMetadataItemUsedInDropdown)) {
fieldMetadataItemUsedInDropdown, handleSelectFilter(
subFieldName, fieldMetadataItemUsedInDropdown,
); subFieldName,
);
}
}} }}
text={getCompositeSubFieldLabel( text={getCompositeSubFieldLabel(
objectFilterDropdownSubMenuFieldType, objectFilterDropdownSubMenuFieldType,
subFieldName, subFieldName,
)} )}
LeftIcon={getIcon(fieldMetadataItemUsedInDropdown?.icon)} LeftIcon={getIcon(
ICON_NAME_BY_SUB_FIELD[subFieldName] ??
fieldMetadataItemUsedInDropdown?.icon,
)}
/> />
</SelectableListItem> </SelectableListItem>
))} ))}

View File

@ -6,6 +6,7 @@ import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/cons
import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/TextFilterTypes'; import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/TextFilterTypes';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState'; import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands'; import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
import { isExpectedSubFieldName } from '@/object-record/object-filter-dropdown/utils/isExpectedSubFieldName';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset'; import { DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
@ -13,6 +14,7 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
const StyledValueDropdownContainer = styled.div` const StyledValueDropdownContainer = styled.div`
@ -60,6 +62,16 @@ export const AdvancedFilterValueInput = ({
? ({ y: -33, x: 0 } satisfies DropdownOffset) ? ({ y: -33, x: 0 } satisfies DropdownOffset)
: DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET; : DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET;
const showFilterTextInput =
(isDefined(filterType) &&
(TEXT_FILTER_TYPES.includes(filterType) ||
NUMBER_FILTER_TYPES.includes(filterType))) ||
isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'amountMicros',
recordFilter.subFieldName,
);
return ( return (
<StyledValueDropdownContainer> <StyledValueDropdownContainer>
{operandHasNoInput ? ( {operandHasNoInput ? (
@ -68,9 +80,7 @@ export const AdvancedFilterValueInput = ({
<AdvancedFilterValueInputDropdownButtonClickableSelect <AdvancedFilterValueInputDropdownButtonClickableSelect
recordFilterId={recordFilterId} recordFilterId={recordFilterId}
/> />
) : isDefined(filterType) && ) : showFilterTextInput ? (
(TEXT_FILTER_TYPES.includes(filterType) ||
NUMBER_FILTER_TYPES.includes(filterType)) ? (
<AdvancedFilterDropdownTextInput recordFilter={recordFilter} /> <AdvancedFilterDropdownTextInput recordFilter={recordFilter} />
) : ( ) : (
<Dropdown <Dropdown

View File

@ -66,6 +66,7 @@ export type DateFilter = {
export type CurrencyFilter = { export type CurrencyFilter = {
amountMicros?: FloatFilter; amountMicros?: FloatFilter;
currencyCode?: SelectFilter;
}; };
export type URLFilter = { export type URLFilter = {

View File

@ -1,9 +1,9 @@
import { ObjectFilterDropdownFilterSelectCompositeFieldSubMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu'; import { ObjectFilterDropdownSubFieldSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSubFieldSelect';
import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput'; import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState'; import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { ObjectFilterDropdownFilterSelect } from './ObjectFilterDropdownFilterSelect'; import { ObjectFilterDropdownFieldSelect } from './ObjectFilterDropdownFieldSelect';
type MultipleFiltersDropdownContentProps = { type MultipleFiltersDropdownContentProps = {
filterDropdownId?: string; filterDropdownId?: string;
@ -35,9 +35,9 @@ export const MultipleFiltersDropdownContent = ({
filterDropdownId={filterDropdownId} filterDropdownId={filterDropdownId}
/> />
) : shouldShowCompositeSelectionSubMenu ? ( ) : shouldShowCompositeSelectionSubMenu ? (
<ObjectFilterDropdownFilterSelectCompositeFieldSubMenu /> <ObjectFilterDropdownSubFieldSelect />
) : ( ) : (
<ObjectFilterDropdownFilterSelect isAdvancedFilterButtonVisible /> <ObjectFilterDropdownFieldSelect isAdvancedFilterButtonVisible />
)} )}
</> </>
); );

View File

@ -0,0 +1,220 @@
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownSelectedRecordIdsComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState';
import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { turnCurrencyIntoSelectableItem } from '@/object-record/object-filter-dropdown/utils/turnCurrencyIntoSelectableItem';
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { StyledMultipleSelectDropdownAvatarChip } from '@/object-record/select/components/StyledMultipleSelectDropdownAvatarChip';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useLingui } from '@lingui/react/macro';
import { ChangeEvent, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { MenuItem, MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
import { v4 } from 'uuid';
export const EMPTY_FILTER_VALUE = '[]';
export const MAX_ITEMS_TO_DISPLAY = 3;
type ObjectFilterDropdownCurrencySelectProps = {
viewComponentId?: string;
dropdownWidth?: number;
};
export const ObjectFilterDropdownCurrencySelect = ({
viewComponentId,
dropdownWidth,
}: ObjectFilterDropdownCurrencySelectProps) => {
const [searchText, setSearchText] = useState('');
const selectedFilter = useRecoilComponentValueV2(
selectedFilterComponentState,
);
const setObjectFilterDropdownSelectedRecordIds = useSetRecoilComponentStateV2(
objectFilterDropdownSelectedRecordIdsComponentState,
selectedFilter?.id,
);
const objectFilterDropdownSelectedRecordIds = useRecoilComponentValueV2(
objectFilterDropdownSelectedRecordIdsComponentState,
selectedFilter?.id,
);
const selectedOperandInDropdown = useRecoilComponentValueV2(
selectedOperandInDropdownComponentState,
);
const fieldMetadataItemUsedInFilterDropdown = useRecoilComponentValueV2(
fieldMetadataItemUsedInDropdownComponentSelector,
);
const { applyRecordFilter } = useApplyRecordFilter(viewComponentId);
const currenciesAsSelectableItems = CURRENCIES.map(
turnCurrencyIntoSelectableItem,
);
const filteredSelectableItems = currenciesAsSelectableItems.filter(
(selectableItem) =>
selectableItem.name.toLowerCase().includes(searchText.toLowerCase()) &&
!objectFilterDropdownSelectedRecordIds.includes(selectableItem.id),
);
const filteredSelectedItems = currenciesAsSelectableItems.filter(
(selectableItem) =>
selectableItem.name.toLowerCase().includes(searchText.toLowerCase()) &&
objectFilterDropdownSelectedRecordIds.includes(selectableItem.id),
);
const currentRecordFilters = useRecoilComponentValueV2(
currentRecordFiltersComponentState,
);
const handleMultipleItemSelectChange = (
itemToSelect: SelectableItem,
newSelectedValue: boolean,
) => {
const newSelectedItemIds = newSelectedValue
? [...objectFilterDropdownSelectedRecordIds, itemToSelect.id]
: objectFilterDropdownSelectedRecordIds.filter(
(id) => id !== itemToSelect.id,
);
if (!isDefined(fieldMetadataItemUsedInFilterDropdown)) {
throw new Error(
'Field metadata item used in filter dropdown should be defined',
);
}
setObjectFilterDropdownSelectedRecordIds(newSelectedItemIds);
const selectedItemNames = currenciesAsSelectableItems
.filter((option) => newSelectedItemIds.includes(option.id))
.map((option) => option.name);
const filterDisplayValue =
selectedItemNames.length > MAX_ITEMS_TO_DISPLAY
? `${selectedItemNames.length} currencies`
: selectedItemNames.join(', ');
if (
isDefined(fieldMetadataItemUsedInFilterDropdown) &&
isDefined(selectedOperandInDropdown)
) {
const newFilterValue =
newSelectedItemIds.length > 0
? JSON.stringify(newSelectedItemIds)
: EMPTY_FILTER_VALUE;
const duplicateFilterInCurrentRecordFilters =
findDuplicateRecordFilterInNonAdvancedRecordFilters({
recordFilters: currentRecordFilters,
fieldMetadataItemId: fieldMetadataItemUsedInFilterDropdown.id,
subFieldName: 'currencyCode',
});
const filterIsAlreadyInCurrentRecordFilters = isDefined(
duplicateFilterInCurrentRecordFilters,
);
const filterId = filterIsAlreadyInCurrentRecordFilters
? duplicateFilterInCurrentRecordFilters?.id
: v4();
applyRecordFilter({
id: selectedFilter?.id ? selectedFilter.id : filterId,
type: getFilterTypeFromFieldType(
fieldMetadataItemUsedInFilterDropdown.type,
),
label: fieldMetadataItemUsedInFilterDropdown.label,
operand: selectedOperandInDropdown || ViewFilterOperand.Is,
displayValue: filterDisplayValue,
fieldMetadataId: fieldMetadataItemUsedInFilterDropdown.id,
value: newFilterValue,
recordFilterGroupId: selectedFilter?.recordFilterGroupId,
subFieldName: 'currencyCode',
positionInRecordFilterGroup:
selectedFilter?.positionInRecordFilterGroup,
});
}
};
const showNoResult =
filteredSelectableItems.length === 0 &&
filteredSelectedItems.length === 0 &&
searchText !== '';
const { t } = useLingui();
return (
<>
<DropdownMenuSearchInput
autoFocus
type="text"
value={searchText}
placeholder={t`Search currency`}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
}}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight width={dropdownWidth ?? 200}>
{filteredSelectedItems?.map((item) => {
return (
<MenuItemMultiSelectAvatar
key={item.id}
selected={true}
onSelectChange={(newCheckedValue) => {
handleMultipleItemSelectChange(item, newCheckedValue);
}}
avatar={
<StyledMultipleSelectDropdownAvatarChip
className="avatar-icon-container"
name={item.name}
avatarUrl={item.avatarUrl}
LeftIcon={item.AvatarIcon}
avatarType={item.avatarType}
isIconInverted={item.isIconInverted}
placeholderColorSeed={item.id}
/>
}
/>
);
})}
{filteredSelectableItems?.map((item) => {
return (
<MenuItemMultiSelectAvatar
key={item.id}
selected={false}
onSelectChange={(newCheckedValue) => {
handleMultipleItemSelectChange(item, newCheckedValue);
}}
avatar={
<StyledMultipleSelectDropdownAvatarChip
className="avatar-icon-container"
name={item.name}
avatarUrl={item.avatarUrl}
LeftIcon={item.AvatarIcon}
avatarType={item.avatarType}
isIconInverted={item.isIconInverted}
placeholderColorSeed={item.id}
/>
}
/>
);
})}
{showNoResult && <MenuItem text={t`No results`} />}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -47,13 +47,13 @@ export const StyledInput = styled.input`
} }
`; `;
type ObjectFilterDropdownFilterSelectProps = { type ObjectFilterDropdownFieldSelectProps = {
isAdvancedFilterButtonVisible?: boolean; isAdvancedFilterButtonVisible?: boolean;
}; };
export const ObjectFilterDropdownFilterSelect = ({ export const ObjectFilterDropdownFieldSelect = ({
isAdvancedFilterButtonVisible, isAdvancedFilterButtonVisible,
}: ObjectFilterDropdownFilterSelectProps) => { }: ObjectFilterDropdownFieldSelectProps) => {
const { recordIndexId } = useRecordIndexContextOrThrow(); const { recordIndexId } = useRecordIndexContextOrThrow();
const [objectFilterDropdownSearchInput, setObjectFilterDropdownSearchInput] = const [objectFilterDropdownSearchInput, setObjectFilterDropdownSearchInput] =

View File

@ -10,6 +10,7 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect'; import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect';
import { ObjectFilterDropdownCurrencySelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect';
import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput'; import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput';
import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes'; import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes';
import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/NumberFilterTypes'; import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/NumberFilterTypes';
@ -17,8 +18,10 @@ import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/consta
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { isExpectedSubFieldName } from '@/object-record/object-filter-dropdown/utils/isExpectedSubFieldName';
import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField'; import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
type ObjectFilterDropdownFilterInputProps = { type ObjectFilterDropdownFilterInputProps = {
@ -105,6 +108,26 @@ export const ObjectFilterDropdownFilterInput = ({
<ObjectFilterDropdownTextInput /> <ObjectFilterDropdownTextInput />
</> </>
))} ))}
{filterType === 'CURRENCY' &&
(isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'currencyCode',
subFieldNameUsedInDropdown,
) ? (
<>
<ObjectFilterDropdownCurrencySelect />
</>
) : isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'amountMicros',
subFieldNameUsedInDropdown,
) ? (
<>
<ObjectFilterDropdownNumberInput />
</>
) : (
<></>
))}
{['SELECT', 'MULTI_SELECT'].includes(filterType) && ( {['SELECT', 'MULTI_SELECT'].includes(filterType) && (
<> <>
<ObjectFilterDropdownSearchInput /> <ObjectFilterDropdownSearchInput />

View File

@ -9,7 +9,7 @@ import { selectedOperandInDropdownComponentState } from '@/object-record/object-
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters'; import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
@ -113,7 +113,9 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
const Icon = getIcon(fieldMetadataItemToSelect.icon); const Icon = getIcon(fieldMetadataItemToSelect.icon);
const shouldShowSubMenu = isCompositeField(fieldMetadataItemToSelect.type); const shouldShowSubMenu = isCompositeFieldType(
fieldMetadataItemToSelect.type,
);
const handleClick = () => { const handleClick = () => {
resetSelectedItem(); resetSelectedItem();
@ -122,7 +124,7 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
fieldMetadataItemToSelect.type, fieldMetadataItemToSelect.type,
); );
if (isCompositeField(filterType)) { if (isCompositeFieldType(filterType)) {
setObjectFilterDropdownSubMenuFieldType(filterType); setObjectFilterDropdownSubMenuFieldType(filterType);
setFieldMetadataItemIdUsedInDropdown(fieldMetadataItemToSelect.id); setFieldMetadataItemIdUsedInDropdown(fieldMetadataItemToSelect.id);

View File

@ -1,7 +1,7 @@
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
@ -28,7 +28,9 @@ export const ObjectFilterDropdownFilterSelectMenuItemV2 = ({
const Icon = getIcon(fieldMetadataItemToSelect.icon); const Icon = getIcon(fieldMetadataItemToSelect.icon);
const shouldShowSubMenu = isCompositeField(fieldMetadataItemToSelect.type); const shouldShowSubMenu = isCompositeFieldType(
fieldMetadataItemToSelect.type,
);
const handleClick = () => { const handleClick = () => {
resetSelectedItem(); resetSelectedItem();

View File

@ -5,6 +5,7 @@ import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldM
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter'; import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -23,6 +24,10 @@ export const ObjectFilterDropdownNumberInput = () => {
selectedFilterComponentState, selectedFilterComponentState,
); );
const subFieldNameUsedInDropdown = useRecoilComponentValueV2(
subFieldNameUsedInDropdownComponentState,
);
const { applyRecordFilter } = useApplyRecordFilter(); const { applyRecordFilter } = useApplyRecordFilter();
const [hasFocused, setHasFocused] = useState(false); const [hasFocused, setHasFocused] = useState(false);
@ -70,7 +75,7 @@ export const ObjectFilterDropdownNumberInput = () => {
recordFilterGroupId: selectedFilter?.recordFilterGroupId, recordFilterGroupId: selectedFilter?.recordFilterGroupId,
positionInRecordFilterGroup: positionInRecordFilterGroup:
selectedFilter?.positionInRecordFilterGroup, selectedFilter?.positionInRecordFilterGroup,
subFieldName: selectedFilter?.subFieldName, subFieldName: subFieldNameUsedInDropdown,
}); });
}} }}
/> />

View File

@ -1,5 +1,6 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFieldSelect';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState'; import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
@ -13,10 +14,12 @@ import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope'; import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { getFilterableFieldTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel'; import { getFilterableFieldTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel';
import { ICON_NAME_BY_SUB_FIELD } from '@/object-record/record-filter/constants/IconNameBySubField';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { areCompositeTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters'; import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { isCompositeFieldTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/isCompositeFieldTypeFilterable'; import { isCompositeTypeFilterableByAnySubField } from '@/object-record/record-filter/utils/isCompositeTypeFilterableByAnySubField';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
@ -32,8 +35,8 @@ import { isDefined } from 'twenty-shared/utils';
import { IconApps, IconChevronLeft, useIcons } from 'twenty-ui/display'; import { IconApps, IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation'; import { MenuItem } from 'twenty-ui/navigation';
export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => { export const ObjectFilterDropdownSubFieldSelect = () => {
const [searchText] = useState(''); const [searchText, setSearchText] = useState('');
const { getIcon } = useIcons(); const { getIcon } = useIcons();
@ -154,7 +157,11 @@ export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => {
const subFieldsAreFilterable = const subFieldsAreFilterable =
isDefined(fieldMetadataItemUsedInDropdown) && isDefined(fieldMetadataItemUsedInDropdown) &&
isCompositeFieldTypeSubFieldsFilterable( areCompositeTypeSubFieldsFilterable(fieldMetadataItemUsedInDropdown.type);
const compositeFieldTypeFilterableByAnySubField =
isDefined(fieldMetadataItemUsedInDropdown) &&
isCompositeTypeFilterableByAnySubField(
fieldMetadataItemUsedInDropdown.type, fieldMetadataItemUsedInDropdown.type,
); );
@ -170,41 +177,41 @@ export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => {
> >
{getFilterableFieldTypeLabel(objectFilterDropdownSubMenuFieldType)} {getFilterableFieldTypeLabel(objectFilterDropdownSubMenuFieldType)}
</DropdownMenuHeader> </DropdownMenuHeader>
{/* <StyledInput <StyledInput
value={searchText} value={searchText}
autoFocus autoFocus
placeholder="Search fields" placeholder="Search fields"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearchText(event.target.value) setSearchText(event.target.value)
} }
/> */} />
<DropdownMenuItemsContainer> <DropdownMenuItemsContainer>
<SelectableList <SelectableList
hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton} hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton}
selectableItemIdArray={['-1', ...options]} selectableItemIdArray={['-1', ...options]}
selectableListInstanceId={OBJECT_FILTER_DROPDOWN_ID} selectableListInstanceId={OBJECT_FILTER_DROPDOWN_ID}
> >
<SelectableListItem {compositeFieldTypeFilterableByAnySubField ? (
itemId={'-1'} <SelectableListItem
key={`select-filter-${-1}`} itemId={'-1'}
onEnter={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown);
}}
>
<MenuItem
focused={selectedItemId === '-1'}
key={`select-filter-${-1}`} key={`select-filter-${-1}`}
testId={`select-filter-${-1}`} onEnter={() => {
onClick={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown); handleSelectFilter(fieldMetadataItemUsedInDropdown);
}} }}
LeftIcon={IconApps} >
text={`Any ${getFilterableFieldTypeLabel( <MenuItem
objectFilterDropdownSubMenuFieldType, key={`select-filter-${-1}`}
)} field`} testId={`select-filter-${-1}`}
/> onClick={() => {
</SelectableListItem> handleSelectFilter(fieldMetadataItemUsedInDropdown);
}}
LeftIcon={IconApps}
text={`Any ${getFilterableFieldTypeLabel(objectFilterDropdownSubMenuFieldType)} field`}
/>
</SelectableListItem>
) : (
<></>
)}
{subFieldsAreFilterable && {subFieldsAreFilterable &&
options.map((subFieldName, index) => ( options.map((subFieldName, index) => (
<SelectableListItem <SelectableListItem
@ -233,7 +240,10 @@ export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => {
objectFilterDropdownSubMenuFieldType, objectFilterDropdownSubMenuFieldType,
subFieldName, subFieldName,
)} )}
LeftIcon={getIcon(fieldMetadataItemUsedInDropdown?.icon)} LeftIcon={getIcon(
ICON_NAME_BY_SUB_FIELD[subFieldName] ??
fieldMetadataItemUsedInDropdown?.icon,
)}
/> />
</SelectableListItem> </SelectableListItem>
))} ))}

View File

@ -1 +1 @@
export const NUMBER_FILTER_TYPES = ['NUMBER', 'CURRENCY', 'PHONES']; export const NUMBER_FILTER_TYPES = ['NUMBER', 'PHONES'];

View File

@ -8,10 +8,11 @@ import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApp
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope'; import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
type SelectFilterParams = { type SelectFilterParams = {
fieldMetadataItemId: string; fieldMetadataItemId: string;
@ -59,8 +60,12 @@ export const useSelectFilterUsedInDropdown = (componentInstanceId?: string) => {
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type); const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
const defaultSubFieldName =
getDefaultSubFieldNameForCompositeFilterableFieldType(filterType);
const firstOperand = getRecordFilterOperands({ const firstOperand = getRecordFilterOperands({
filterType, filterType,
subFieldName: defaultSubFieldName,
})[0]; })[0];
setSelectedOperandInDropdown(firstOperand); setSelectedOperandInDropdown(firstOperand);
@ -79,6 +84,7 @@ export const useSelectFilterUsedInDropdown = (componentInstanceId?: string) => {
value, value,
type: filterType, type: filterType,
label: fieldMetadataItem.label, label: fieldMetadataItem.label,
subFieldName: defaultSubFieldName,
}); });
} }

View File

@ -1,6 +1,8 @@
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { FieldType } from '@/settings/data-model/types/FieldType';
describe('getOperandsForFilterType', () => { describe('getOperandsForFilterType', () => {
const emptyOperands = [ const emptyOperands = [
@ -18,6 +20,18 @@ describe('getOperandsForFilterType', () => {
RecordFilterOperand.LessThan, RecordFilterOperand.LessThan,
]; ];
const currencyAmountMicrosOperands = [
RecordFilterOperand.GreaterThan,
RecordFilterOperand.LessThan,
RecordFilterOperand.Is,
RecordFilterOperand.IsNot,
];
const currencyCurrencyCodeOperands = [
RecordFilterOperand.Is,
RecordFilterOperand.IsNot,
];
const dateOperands = [ const dateOperands = [
RecordFilterOperand.Is, RecordFilterOperand.Is,
RecordFilterOperand.IsRelative, RecordFilterOperand.IsRelative,
@ -36,7 +50,16 @@ describe('getOperandsForFilterType', () => {
['ADDRESS', [...containsOperands, ...emptyOperands]], ['ADDRESS', [...containsOperands, ...emptyOperands]],
['LINKS', [...containsOperands, ...emptyOperands]], ['LINKS', [...containsOperands, ...emptyOperands]],
['ACTOR', [...containsOperands, ...emptyOperands]], ['ACTOR', [...containsOperands, ...emptyOperands]],
['CURRENCY', [...numberOperands, ...emptyOperands]], [
'CURRENCY',
[...currencyCurrencyCodeOperands, ...emptyOperands],
'currencyCode',
],
[
'CURRENCY',
[...currencyAmountMicrosOperands, ...emptyOperands],
'amountMicros',
],
['NUMBER', [...numberOperands, ...emptyOperands]], ['NUMBER', [...numberOperands, ...emptyOperands]],
['DATE', [...dateOperands, ...emptyOperands]], ['DATE', [...dateOperands, ...emptyOperands]],
['DATE_TIME', [...dateOperands, ...emptyOperands]], ['DATE_TIME', [...dateOperands, ...emptyOperands]],
@ -44,12 +67,20 @@ describe('getOperandsForFilterType', () => {
[undefined, []], [undefined, []],
[null, []], [null, []],
['UNKNOWN_TYPE', []], ['UNKNOWN_TYPE', []],
]; ] satisfies (
| [
FieldType | null | undefined | 'UNKNOWN_TYPE',
RecordFilterOperand[],
CompositeFieldSubFieldName,
]
| [FieldType | null | undefined | 'UNKNOWN_TYPE', RecordFilterOperand[]]
)[];
testCases.forEach(([filterType, expectedOperands]) => { testCases.forEach(([filterType, expectedOperands, subFieldName]) => {
it(`should return correct operands for FilterType.${filterType}`, () => { it(`should return correct operands for FilterType.${filterType}`, () => {
const result = getRecordFilterOperands({ const result = getRecordFilterOperands({
filterType: filterType as FilterableFieldType, filterType: filterType as FilterableFieldType,
subFieldName,
}); });
expect(result).toEqual(expectedOperands); expect(result).toEqual(expectedOperands);
}); });

View File

@ -4,5 +4,6 @@ import {
} from '@/settings/data-model/types/CompositeFieldType'; } from '@/settings/data-model/types/CompositeFieldType';
import { FieldType } from '@/settings/data-model/types/FieldType'; import { FieldType } from '@/settings/data-model/types/FieldType';
export const isCompositeField = (type: FieldType): type is CompositeFieldType => export const isCompositeFieldType = (
COMPOSITE_FIELD_TYPES.includes(type as any); type: FieldType,
): type is CompositeFieldType => COMPOSITE_FIELD_TYPES.includes(type as any);

View File

@ -0,0 +1,10 @@
import { CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType';
import { FILTERABLE_FIELD_TYPES } from '@/object-record/record-filter/types/FilterableFieldType';
import { COMPOSITE_FIELD_TYPES } from '@/settings/data-model/types/CompositeFieldType';
import { FieldType } from '@/settings/data-model/types/FieldType';
export const isCompositeFilterableFieldType = (
type: FieldType,
): type is CompositeFilterableFieldType =>
FILTERABLE_FIELD_TYPES.includes(type as any) &&
COMPOSITE_FIELD_TYPES.includes(type as any);

View File

@ -0,0 +1,20 @@
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
export const isExpectedSubFieldName = <
GivenFieldType extends keyof typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS,
CompositeFieldTypeSettings extends
typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS,
PossibleSubFieldsForGivenFieldType extends
CompositeFieldTypeSettings[GivenFieldType]['subFields'][number],
>(
fieldMetadataType: GivenFieldType,
subFieldName: PossibleSubFieldsForGivenFieldType,
subFieldNameToCheck: string | null | undefined,
): subFieldNameToCheck is PossibleSubFieldsForGivenFieldType => {
return (
(
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataType]
.subFields as string[]
).includes(subFieldName) && subFieldName === subFieldNameToCheck
);
};

View File

@ -0,0 +1,12 @@
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { Currency } from '@/ui/input/components/internal/types/Currency';
export const turnCurrencyIntoSelectableItem = (
currency: Currency,
): SelectableItem => ({
id: currency.value,
AvatarIcon: currency.Icon,
avatarType: 'icon',
name: `${currency.label}`,
isSelected: false,
});

View File

@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect'; import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFieldSelect';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { useSearchRecordGroupField } from '@/object-record/object-options-dropdown/hooks/useSearchRecordGroupField'; import { useSearchRecordGroupField } from '@/object-record/object-options-dropdown/hooks/useSearchRecordGroupField';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';

View File

@ -5,7 +5,7 @@ import { FormSelectFieldInput } from '@/object-record/record-field/form-types/co
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FormFieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; import { FormFieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes'; import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { InputLabel } from '@/ui/input/components/InputLabel'; import { InputLabel } from '@/ui/input/components/InputLabel';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IconCircleOff } from 'twenty-ui/display'; import { IconCircleOff } from 'twenty-ui/display';
@ -26,21 +26,13 @@ export const FormCurrencyFieldInput = ({
readonly, readonly,
}: FormCurrencyFieldInputProps) => { }: FormCurrencyFieldInputProps) => {
const currencies = useMemo(() => { const currencies = useMemo(() => {
const currencies = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([key, { Icon, label }]) => ({
value: key,
Icon,
label: `${label} (${key})`,
}),
);
return [ return [
{ {
label: 'No currency', label: 'No currency',
value: '', value: '',
Icon: IconCircleOff, Icon: IconCircleOff,
}, },
...currencies, ...CURRENCIES,
]; ];
}, []); }, []);

View File

@ -0,0 +1,8 @@
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
export const ICON_NAME_BY_SUB_FIELD: Partial<
Record<CompositeFieldSubFieldName, string>
> = {
currencyCode: 'IconCurrencyDollar',
amountMicros: 'IconNumber95Small',
};

View File

@ -1,24 +1,30 @@
import { FieldType } from '@/settings/data-model/types/FieldType'; import { FieldType } from '@/settings/data-model/types/FieldType';
import { PickLiteral } from '~/types/PickLiteral'; import { PickLiteral } from '~/types/PickLiteral';
export const FILTERABLE_FIELD_TYPES = [
'TEXT',
'PHONES',
'EMAILS',
'DATE_TIME',
'DATE',
'NUMBER',
'CURRENCY',
'FULL_NAME',
'LINKS',
'RELATION',
'ADDRESS',
'SELECT',
'RATING',
'MULTI_SELECT',
'ACTOR',
'ARRAY',
'RAW_JSON',
'BOOLEAN',
] as const;
type FilterableFieldTypeBaseLiteral = (typeof FILTERABLE_FIELD_TYPES)[number];
export type FilterableFieldType = PickLiteral< export type FilterableFieldType = PickLiteral<
FieldType, FieldType,
| 'TEXT' FilterableFieldTypeBaseLiteral
| 'PHONES'
| 'EMAILS'
| 'DATE_TIME'
| 'DATE'
| 'NUMBER'
| 'CURRENCY'
| 'FULL_NAME'
| 'LINKS'
| 'RELATION'
| 'ADDRESS'
| 'SELECT'
| 'RATING'
| 'MULTI_SELECT'
| 'ACTOR'
| 'ARRAY'
| 'RAW_JSON'
| 'BOOLEAN'
>; >;

View File

@ -1,3 +1,4 @@
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies'; import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
@ -934,6 +935,179 @@ describe('should work as expected for the different field types', () => {
}); });
}); });
it('currency amount micros sub field type', () => {
const companyMockARRFieldMetadataId =
companyMockObjectMetadataItem.fields.find(
(field) => field.name === 'annualRecurringRevenue',
);
const ARRFilterIsGreaterThan: RecordFilter = {
id: 'company-ARR-filter-is-greater-than',
value: '1000',
fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: '1000',
operand: RecordFilterOperand.GreaterThan,
subFieldName: 'amountMicros' satisfies Extract<
keyof FieldCurrencyValue,
'amountMicros'
>,
label: 'Amount',
type: FieldMetadataType.CURRENCY,
};
const ARRFilterIsLessThan: RecordFilter = {
id: 'company-ARR-filter-is-less-than',
value: '1000',
fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: '1000',
operand: RecordFilterOperand.LessThan,
subFieldName: 'amountMicros' satisfies Extract<
keyof FieldCurrencyValue,
'amountMicros'
>,
label: 'Amount',
type: FieldMetadataType.CURRENCY,
};
const ARRFilterIs: RecordFilter = {
id: 'company-ARR-filter-is',
value: '1000',
fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: '1000',
operand: RecordFilterOperand.Is,
subFieldName: 'amountMicros' satisfies Extract<
keyof FieldCurrencyValue,
'amountMicros'
>,
label: 'Amount',
type: FieldMetadataType.CURRENCY,
};
const ARRFilterIsNot: RecordFilter = {
id: 'company-ARR-filter-is-not',
value: '1000',
fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: '1000',
operand: RecordFilterOperand.IsNot,
subFieldName: 'amountMicros' satisfies Extract<
keyof FieldCurrencyValue,
'amountMicros'
>,
label: 'Amount',
type: FieldMetadataType.CURRENCY,
};
const result = computeRecordGqlOperationFilter({
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [
ARRFilterIsGreaterThan,
ARRFilterIsLessThan,
ARRFilterIs,
ARRFilterIsNot,
],
recordFilterGroups: [],
fields: companyMockObjectMetadataItem.fields,
});
expect(result).toEqual({
and: [
{
annualRecurringRevenue: {
amountMicros: {
gte: 1000 * 1000000,
},
},
},
{
annualRecurringRevenue: {
amountMicros: {
lte: 1000 * 1000000,
},
},
},
{
annualRecurringRevenue: {
amountMicros: {
eq: 1000 * 1000000,
},
},
},
{
not: {
annualRecurringRevenue: {
amountMicros: {
eq: 1000 * 1000000,
},
},
},
},
],
});
});
it('currency currency code sub field type', () => {
const companyMockARRFieldMetadataId =
companyMockObjectMetadataItem.fields.find(
(field) => field.name === 'annualRecurringRevenue',
);
const ARRFilterIn: RecordFilter = {
id: 'company-ARR-filter-in',
value: '["USD"]',
fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: 'USD',
operand: RecordFilterOperand.Is,
subFieldName: 'currencyCode' satisfies Extract<
keyof FieldCurrencyValue,
'currencyCode'
>,
label: 'Currency',
type: FieldMetadataType.CURRENCY,
};
const ARRFilterNotIn: RecordFilter = {
id: 'company-ARR-filter-not-in',
value: '["USD"]',
fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: 'Not USD',
operand: RecordFilterOperand.IsNot,
subFieldName: 'currencyCode' satisfies Extract<
keyof FieldCurrencyValue,
'currencyCode'
>,
label: 'Currency',
type: FieldMetadataType.CURRENCY,
};
const result = computeRecordGqlOperationFilter({
filterValueDependencies: mockFilterValueDependencies,
recordFilters: [ARRFilterIn, ARRFilterNotIn],
recordFilterGroups: [],
fields: companyMockObjectMetadataItem.fields,
});
expect(result).toEqual({
and: [
{
annualRecurringRevenue: {
currencyCode: {
in: ['USD'],
},
},
},
{
not: {
annualRecurringRevenue: {
currencyCode: {
in: ['USD'],
},
},
},
},
],
});
});
it('select field type with empty options', () => { it('select field type with empty options', () => {
const selectFieldMetadata = companyMockObjectMetadataItem.fields.find( const selectFieldMetadata = companyMockObjectMetadataItem.fields.find(
(field) => field.type === FieldMetadataType.SELECT, (field) => field.type === FieldMetadataType.SELECT,

View File

@ -2,162 +2,326 @@ import { CurrencyFilter } from '@/object-record/graphql/types/RecordGqlOperation
import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter'; import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
describe('isMatchingCurrencyFilter', () => { describe('isMatchingCurrencyFilter', () => {
describe('eq', () => { describe('amountMicros', () => {
it('value equals eq filter', () => { describe('eq', () => {
it('value equals eq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { eq: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: {
amountMicros: 10,
},
}),
).toBe(true);
});
it('value does not equal eq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { eq: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 20 },
}),
).toBe(false);
});
});
describe('gt', () => {
it('value is greater than gt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gt: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 20 },
}),
).toBe(true);
});
it('value is not greater than gt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gt: 20 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toBe(false);
});
});
describe('gte', () => {
it('value is greater than or equal to gte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gte: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toBe(true);
});
it('value is not greater than or equal to gte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gte: 20 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toBe(false);
});
});
describe('lt', () => {
it('value is less than lt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lt: 20 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toBe(true);
});
it('value is not less than lt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lt: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 20 },
}),
).toBe(false);
});
});
describe('lte', () => {
it('value is less than or equal to lte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lte: 20 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 20 },
}),
).toBe(true);
});
it('value is not less than or equal to lte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lte: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 20 },
}),
).toBe(false);
});
});
describe('neq', () => {
it('value does not equal neq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { neq: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 20 },
}),
).toBe(true);
});
it('value equals neq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { neq: 10 },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { is: 'NULL' },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: null as any },
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { is: 'NOT_NULL' },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toBe(true);
});
});
});
describe('currencyCode', () => {
describe('in', () => {
it('value is in filter array', () => {
const currencyFilter: CurrencyFilter = {
currencyCode: { in: ['USD'] },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { currencyCode: 'USD' },
}),
).toBe(true);
});
it('value is not in filter array', () => {
const currencyFilter: CurrencyFilter = {
currencyCode: { in: ['USD'] },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { currencyCode: 'EUR' },
}),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
const currencyFilter: CurrencyFilter = {
currencyCode: { is: 'NULL' },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { currencyCode: null as any },
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
const currencyFilter: CurrencyFilter = {
currencyCode: { is: 'NOT_NULL' },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: { currencyCode: 'USD' },
}),
).toBe(true);
});
});
});
describe('both filters', () => {
it('both filters match', () => {
const currencyFilter: CurrencyFilter = { const currencyFilter: CurrencyFilter = {
amountMicros: { eq: 10 }, amountMicros: { eq: 10 },
}; currencyCode: { in: ['USD'] },
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe(
true,
);
});
it('value does not equal eq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { eq: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
false,
);
});
});
describe('gt', () => {
it('value is greater than gt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gt: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
true,
);
});
it('value is not greater than gt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gt: 20 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe(
false,
);
});
});
describe('gte', () => {
it('value is greater than or equal to gte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gte: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe(
true,
);
});
it('value is not greater than or equal to gte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { gte: 20 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe(
false,
);
});
});
describe('in', () => {
it('value is in the array', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { in: [10, 20, 30] },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
true,
);
});
it('value is not in the array', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { in: [10, 30, 40] },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
false,
);
});
});
describe('lt', () => {
it('value is less than lt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lt: 20 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe(
true,
);
});
it('value is not less than lt filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lt: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
false,
);
});
});
describe('lte', () => {
it('value is less than or equal to lte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lte: 20 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
true,
);
});
it('value is not less than or equal to lte filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { lte: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
false,
);
});
});
describe('neq', () => {
it('value does not equal neq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { neq: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 20 })).toBe(
true,
);
});
it('value equals neq filter', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { neq: 10 },
};
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe(
false,
);
});
});
describe('is', () => {
it('value is NULL', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { is: 'NULL' },
}; };
expect( expect(
isMatchingCurrencyFilter({ currencyFilter, value: null as any }), isMatchingCurrencyFilter({
currencyFilter,
value: {
amountMicros: 10,
currencyCode: 'USD',
},
}),
).toBe(true); ).toBe(true);
}); });
it('value is NOT_NULL', () => { it('amount micros filter does not match', () => {
const currencyFilter: CurrencyFilter = { const currencyFilter: CurrencyFilter = {
amountMicros: { is: 'NOT_NULL' }, amountMicros: { eq: 10 },
currencyCode: { in: ['USD'] },
}; };
expect(isMatchingCurrencyFilter({ currencyFilter, value: 10 })).toBe( expect(
true, isMatchingCurrencyFilter({
currencyFilter,
value: {
amountMicros: 20,
currencyCode: 'USD',
},
}),
).toBe(false);
});
it('currency code filter does not match', () => {
const currencyFilter: CurrencyFilter = {
amountMicros: { eq: 10 },
currencyCode: { in: ['USD'] },
};
expect(
isMatchingCurrencyFilter({
currencyFilter,
value: {
amountMicros: 10,
currencyCode: 'EUR',
},
}),
).toBe(false);
});
});
describe('no filters', () => {
it('no filters match', () => {
const currencyFilter: CurrencyFilter = {};
expect(() =>
isMatchingCurrencyFilter({
currencyFilter,
value: {
amountMicros: 10,
currencyCode: 'USD',
},
}),
).toThrowError('Unexpected filter for currency : {}');
});
});
describe('unexpected operand', () => {
it('throws an error for unexpected operand', () => {
const currencyFilter: any = {
amountMicros: { unexpected: 10 },
};
expect(() =>
isMatchingCurrencyFilter({
currencyFilter,
value: { amountMicros: 10 },
}),
).toThrowError(
'Unexpected operand for currency amount micros filter : {"unexpected":10}',
); );
}); });
}); });

View File

@ -0,0 +1,15 @@
import { FieldType } from '@/settings/data-model/types/FieldType';
const COMPOSITE_TYPES_FILTERABLE = [
'ACTOR',
'FULL_NAME',
'CURRENCY',
] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];
export const areCompositeTypeSubFieldsFilterable = (
fieldType: FieldType,
): fieldType is FilterableCompositeFieldType => {
return COMPOSITE_TYPES_FILTERABLE.includes(fieldType as any);
};

View File

@ -39,9 +39,12 @@ import { simpleRelationFilterValueSchema } from '@/views/view-filter-value/valid
import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns'; import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
import { isExpectedSubFieldName } from '@/object-record/object-filter-dropdown/utils/isExpectedSubFieldName';
import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator'; import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
type ComputeFilterRecordGqlOperationFilterParams = { type ComputeFilterRecordGqlOperationFilterParams = {
@ -61,14 +64,11 @@ export const computeFilterRecordGqlOperationFilter = ({
(field) => field.id === filter.fieldMetadataId, (field) => field.id === filter.fieldMetadataId,
); );
const compositeFieldName = filter.subFieldName; const subFieldName = filter.subFieldName;
const isCompositeFieldFiter = isNonEmptyString(compositeFieldName); const isSubFieldFilter = isNonEmptyString(subFieldName);
const isEmptinessOperand = [ const isAnEmptinessOperand = isEmptinessOperand(filter.operand);
RecordFilterOperand.IsEmpty,
RecordFilterOperand.IsNotEmpty,
].includes(filter.operand);
const isDateOperandWithoutValue = [ const isDateOperandWithoutValue = [
RecordFilterOperand.IsInPast, RecordFilterOperand.IsInPast,
@ -85,7 +85,7 @@ export const computeFilterRecordGqlOperationFilter = ({
const isFilterValueEmpty = !isDefined(filter.value) || filter.value === ''; const isFilterValueEmpty = !isDefined(filter.value) || filter.value === '';
const shouldSkipFiltering = const shouldSkipFiltering =
!isEmptinessOperand && !isDateOperandWithoutValue && isFilterValueEmpty; !isAnEmptinessOperand && !isDateOperandWithoutValue && isFilterValueEmpty;
if (shouldSkipFiltering) { if (shouldSkipFiltering) {
return; return;
@ -98,7 +98,7 @@ export const computeFilterRecordGqlOperationFilter = ({
const filterHasEmptinessOperands = const filterHasEmptinessOperands =
!filterTypesThatHaveNoEmptinessOperand.includes(filterType); !filterTypesThatHaveNoEmptinessOperand.includes(filterType);
if (filterHasEmptinessOperands && isEmptinessOperand) { if (filterHasEmptinessOperands && isAnEmptinessOperand) {
const emptyOperationFilter = getEmptyRecordGqlOperationFilter({ const emptyOperationFilter = getEmptyRecordGqlOperationFilter({
operand: filter.operand, operand: filter.operand,
correspondingField, correspondingField,
@ -357,25 +357,82 @@ export const computeFilterRecordGqlOperationFilter = ({
); );
} }
} }
case 'CURRENCY': case 'CURRENCY': {
switch (filter.operand) { if (
case RecordFilterOperand.GreaterThan: isExpectedSubFieldName(
return { FieldMetadataType.CURRENCY,
[correspondingField.name]: { 'currencyCode',
amountMicros: { gte: parseFloat(filter.value) * 1000000 }, subFieldName,
} as CurrencyFilter, )
}; ) {
case RecordFilterOperand.LessThan: const parsedCurrencyCodes = JSON.parse(filter.value) as string[];
return {
[correspondingField.name]: { if (parsedCurrencyCodes.length === 0) return undefined;
amountMicros: { lte: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter, const gqlFilter: RecordGqlOperationFilter = {
}; [correspondingField.name]: {
default: currencyCode: { in: parsedCurrencyCodes },
throw new Error( } as CurrencyFilter,
`Unknown operand ${filter.operand} for ${filterType} filter`, };
);
switch (filter.operand) {
case RecordFilterOperand.Is:
return gqlFilter;
case RecordFilterOperand.IsNot:
return {
not: gqlFilter,
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} / ${subFieldName} filter`,
);
}
} else if (
isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'amountMicros',
subFieldName,
)
) {
switch (filter.operand) {
case RecordFilterOperand.GreaterThan:
return {
[correspondingField.name]: {
amountMicros: { gte: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.LessThan:
return {
[correspondingField.name]: {
amountMicros: { lte: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.Is:
return {
[correspondingField.name]: {
amountMicros: { eq: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.IsNot:
return {
not: {
[correspondingField.name]: {
amountMicros: { eq: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter,
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} / ${subFieldName} filter`,
);
}
} else {
throw new Error(
`Unknown subfield ${subFieldName} for ${filterType} filter`,
);
} }
}
case 'LINKS': { case 'LINKS': {
const linksFilters = generateILikeFiltersForCompositeFields( const linksFilters = generateILikeFiltersForCompositeFields(
filter.value, filter.value,
@ -385,21 +442,21 @@ export const computeFilterRecordGqlOperationFilter = ({
switch (filter.operand) { switch (filter.operand) {
case RecordFilterOperand.Contains: case RecordFilterOperand.Contains:
if (!isCompositeFieldFiter) { if (!isSubFieldFilter) {
return { return {
or: linksFilters, or: linksFilters,
}; };
} else { } else {
return { return {
[correspondingField.name]: { [correspondingField.name]: {
[compositeFieldName]: { [subFieldName]: {
ilike: `%${filter.value}%`, ilike: `%${filter.value}%`,
}, },
}, },
}; };
} }
case RecordFilterOperand.DoesNotContain: case RecordFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) { if (!isSubFieldFilter) {
return { return {
and: linksFilters.map((filter) => { and: linksFilters.map((filter) => {
return { return {
@ -411,7 +468,7 @@ export const computeFilterRecordGqlOperationFilter = ({
return { return {
not: { not: {
[correspondingField.name]: { [correspondingField.name]: {
[compositeFieldName]: { [subFieldName]: {
ilike: `%${filter.value}%`, ilike: `%${filter.value}%`,
}, },
}, },
@ -432,21 +489,21 @@ export const computeFilterRecordGqlOperationFilter = ({
); );
switch (filter.operand) { switch (filter.operand) {
case RecordFilterOperand.Contains: case RecordFilterOperand.Contains:
if (!isCompositeFieldFiter) { if (!isSubFieldFilter) {
return { return {
or: fullNameFilters, or: fullNameFilters,
}; };
} else { } else {
return { return {
[correspondingField.name]: { [correspondingField.name]: {
[compositeFieldName]: { [subFieldName]: {
ilike: `%${filter.value}%`, ilike: `%${filter.value}%`,
}, },
}, },
}; };
} }
case RecordFilterOperand.DoesNotContain: case RecordFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) { if (!isSubFieldFilter) {
return { return {
and: fullNameFilters.map((filter) => { and: fullNameFilters.map((filter) => {
return { return {
@ -458,7 +515,7 @@ export const computeFilterRecordGqlOperationFilter = ({
return { return {
not: { not: {
[correspondingField.name]: { [correspondingField.name]: {
[compositeFieldName]: { [subFieldName]: {
ilike: `%${filter.value}%`, ilike: `%${filter.value}%`,
}, },
}, },
@ -474,7 +531,7 @@ export const computeFilterRecordGqlOperationFilter = ({
case 'ADDRESS': case 'ADDRESS':
switch (filter.operand) { switch (filter.operand) {
case RecordFilterOperand.Contains: case RecordFilterOperand.Contains:
if (!isCompositeFieldFiter) { if (!isSubFieldFilter) {
return { return {
or: [ or: [
{ {
@ -524,14 +581,14 @@ export const computeFilterRecordGqlOperationFilter = ({
} else { } else {
return { return {
[correspondingField.name]: { [correspondingField.name]: {
[compositeFieldName]: { [subFieldName]: {
ilike: `%${filter.value}%`, ilike: `%${filter.value}%`,
} as AddressFilter, } as AddressFilter,
}, },
}; };
} }
case RecordFilterOperand.DoesNotContain: case RecordFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) { if (!isSubFieldFilter) {
return { return {
and: [ and: [
{ {
@ -567,7 +624,7 @@ export const computeFilterRecordGqlOperationFilter = ({
return { return {
not: { not: {
[correspondingField.name]: { [correspondingField.name]: {
[compositeFieldName]: { [subFieldName]: {
ilike: `%${filter.value}%`, ilike: `%${filter.value}%`,
} as AddressFilter, } as AddressFilter,
}, },

View File

@ -0,0 +1,34 @@
import { isCompositeFilterableFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFilterableFieldType';
import { CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { FieldType } from '@/settings/data-model/types/FieldType';
import { assertUnreachable } from 'twenty-shared/utils';
export const getDefaultSubFieldNameForCompositeFilterableFieldType = (
fieldType: FieldType,
): CompositeFieldSubFieldName | undefined => {
if (!isCompositeFilterableFieldType(fieldType as any)) {
return undefined;
}
const compositeFieldType = fieldType as CompositeFilterableFieldType;
switch (compositeFieldType) {
case 'CURRENCY':
return 'amountMicros';
case 'LINKS':
return 'primaryLinkUrl';
case 'PHONES':
return 'primaryPhoneNumber';
case 'EMAILS':
return 'primaryEmail';
case 'ADDRESS':
return 'addressCity';
case 'ACTOR':
return 'source';
case 'FULL_NAME':
return 'firstName';
default:
assertUnreachable(compositeFieldType);
}
};

View File

@ -1,6 +1,9 @@
import { isExpectedSubFieldName } from '@/object-record/object-filter-dropdown/utils/isExpectedSubFieldName';
import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField'; import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField';
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType'; import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand as RecordFilterOperand } from '@/views/types/ViewFilterOperand'; import { ViewFilterOperand as RecordFilterOperand } from '@/views/types/ViewFilterOperand';
import { FieldMetadataType } from 'twenty-shared/types';
export type GetRecordFilterOperandsParams = { export type GetRecordFilterOperandsParams = {
filterType: FilterableFieldType; filterType: FilterableFieldType;
@ -21,6 +24,15 @@ type FilterOperandMap = {
[K in FilterableFieldType]: readonly RecordFilterOperand[]; [K in FilterableFieldType]: readonly RecordFilterOperand[];
}; };
// TODO: we would need to refactor the typing of SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS first
// with types like FieldCurrencyValue being derived from a central constant value and not being created like that
// in order to narrow down the possible subfield names for each field type
type CompositeFieldFilterOperandMap = {
[K in FilterableFieldType]: Partial<{
[S in CompositeFieldSubFieldName]: readonly RecordFilterOperand[];
}>;
};
export const FILTER_OPERANDS_MAP = { export const FILTER_OPERANDS_MAP = {
TEXT: [ TEXT: [
RecordFilterOperand.Contains, RecordFilterOperand.Contains,
@ -113,6 +125,23 @@ export const FILTER_OPERANDS_MAP = {
BOOLEAN: [RecordFilterOperand.Is], BOOLEAN: [RecordFilterOperand.Is],
} as const satisfies FilterOperandMap; } as const satisfies FilterOperandMap;
export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = {
CURRENCY: {
currencyCode: [
RecordFilterOperand.Is,
RecordFilterOperand.IsNot,
...emptyOperands,
],
amountMicros: [
RecordFilterOperand.GreaterThan,
RecordFilterOperand.LessThan,
RecordFilterOperand.Is,
RecordFilterOperand.IsNot,
...emptyOperands,
],
},
} as const satisfies Partial<CompositeFieldFilterOperandMap>;
export const getRecordFilterOperands = ({ export const getRecordFilterOperands = ({
filterType, filterType,
subFieldName, subFieldName,
@ -125,7 +154,29 @@ export const getRecordFilterOperands = ({
case 'LINKS': case 'LINKS':
case 'PHONES': case 'PHONES':
return FILTER_OPERANDS_MAP.TEXT; return FILTER_OPERANDS_MAP.TEXT;
case 'CURRENCY': case 'CURRENCY': {
if (
isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'currencyCode',
subFieldName,
)
) {
return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.currencyCode;
} else if (
isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
'amountMicros',
subFieldName,
)
) {
return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.amountMicros;
} else {
throw new Error(
`Unknown subfield name ${subFieldName} for ${filterType} filter`,
);
}
}
case 'NUMBER': case 'NUMBER':
return FILTER_OPERANDS_MAP.NUMBER; return FILTER_OPERANDS_MAP.NUMBER;
case 'RAW_JSON': case 'RAW_JSON':

View File

@ -1,11 +0,0 @@
import { FieldType } from '@/settings/data-model/types/FieldType';
type CompositeFilterableFieldType = Extract<FieldType, 'ACTOR' | 'FULL_NAME'>;
export const isCompositeFieldTypeSubFieldsFilterable = (
fieldType: FieldType,
): fieldType is CompositeFilterableFieldType => {
return (
['ACTOR', 'FULL_NAME'] satisfies CompositeFilterableFieldType[]
).includes(fieldType as any);
};

View File

@ -0,0 +1,15 @@
import { FieldType } from '@/settings/data-model/types/FieldType';
const COMPOSITE_TYPES_NON_FILTERABLE_WITH_ANY = [
'ACTOR',
'CURRENCY',
] satisfies FieldType[];
type CompositeTypeNonFilterableWithAny =
(typeof COMPOSITE_TYPES_NON_FILTERABLE_WITH_ANY)[number];
export const isCompositeTypeFilterableByAnySubField = (
fieldType: FieldType,
): fieldType is CompositeTypeNonFilterableWithAny => {
return !COMPOSITE_TYPES_NON_FILTERABLE_WITH_ANY.includes(fieldType as any);
};

View File

@ -0,0 +1,7 @@
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
export const isEmptinessOperand = (operand: RecordFilterOperand): boolean => {
return [RecordFilterOperand.IsEmpty, RecordFilterOperand.IsNotEmpty].includes(
operand,
);
};

View File

@ -1,36 +1,17 @@
import { CurrencyFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { CurrencyFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export const isMatchingCurrencyFilter = ({ const isMatchingCurrencyCodeFilter = (
currencyFilter, currencyCodeFilter: CurrencyFilter['currencyCode'],
value, value: string | null | undefined,
}: { ) => {
currencyFilter: CurrencyFilter;
value: number;
}) => {
switch (true) { switch (true) {
case currencyFilter.amountMicros?.eq !== undefined: { case currencyCodeFilter?.in !== undefined: {
return value === currencyFilter.amountMicros.eq; return isNonEmptyString(value) && currencyCodeFilter.in.includes(value);
} }
case currencyFilter.amountMicros?.neq !== undefined: { case currencyCodeFilter?.is !== undefined: {
return value !== currencyFilter.amountMicros.neq; if (currencyCodeFilter.is === 'NULL') {
}
case currencyFilter.amountMicros?.gt !== undefined: {
return value > currencyFilter.amountMicros.gt;
}
case currencyFilter.amountMicros?.gte !== undefined: {
return value >= currencyFilter.amountMicros.gte;
}
case currencyFilter.amountMicros?.lt !== undefined: {
return value < currencyFilter.amountMicros.lt;
}
case currencyFilter.amountMicros?.lte !== undefined: {
return value <= currencyFilter.amountMicros.lte;
}
case currencyFilter.amountMicros?.in !== undefined: {
return currencyFilter.amountMicros.in.includes(value);
}
case currencyFilter.amountMicros?.is !== undefined: {
if (currencyFilter.amountMicros.is === 'NULL') {
return value === null; return value === null;
} else { } else {
return value !== null; return value !== null;
@ -38,10 +19,91 @@ export const isMatchingCurrencyFilter = ({
} }
default: { default: {
throw new Error( throw new Error(
`Unexpected amountMicros for currency filter : ${JSON.stringify( `Unexpected operand for currency code filter : ${JSON.stringify(
currencyFilter.amountMicros, currencyCodeFilter,
)}`, )}`,
); );
} }
} }
}; };
const isMatchingAmountMicrosFilter = (
amountMicrosFilter: CurrencyFilter['amountMicros'],
value: number | null | undefined,
) => {
switch (true) {
case amountMicrosFilter?.eq !== undefined: {
return value === amountMicrosFilter.eq;
}
case amountMicrosFilter?.neq !== undefined: {
return value !== amountMicrosFilter.neq;
}
case amountMicrosFilter?.gt !== undefined: {
return isDefined(value) && value > amountMicrosFilter.gt;
}
case amountMicrosFilter?.gte !== undefined: {
return isDefined(value) && value >= amountMicrosFilter.gte;
}
case amountMicrosFilter?.lt !== undefined: {
return isDefined(value) && value < amountMicrosFilter.lt;
}
case amountMicrosFilter?.lte !== undefined: {
return isDefined(value) && value <= amountMicrosFilter.lte;
}
case amountMicrosFilter?.is !== undefined: {
if (amountMicrosFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
default: {
throw new Error(
`Unexpected operand for currency amount micros filter : ${JSON.stringify(
amountMicrosFilter,
)}`,
);
}
}
};
export const isMatchingCurrencyFilter = ({
currencyFilter,
value,
}: {
currencyFilter: CurrencyFilter;
value: {
amountMicros?: number | null;
currencyCode?: string | null;
};
}) => {
const shouldMatchCurrencyCodeFilter = isDefined(currencyFilter.currencyCode);
const shouldMatchAmountMicrosFilter = isDefined(currencyFilter.amountMicros);
if (shouldMatchCurrencyCodeFilter && shouldMatchAmountMicrosFilter) {
return (
isMatchingAmountMicrosFilter(
currencyFilter.amountMicros,
value.amountMicros,
) &&
isMatchingCurrencyCodeFilter(
currencyFilter.currencyCode,
value.currencyCode,
)
);
} else if (shouldMatchAmountMicrosFilter) {
return isMatchingAmountMicrosFilter(
currencyFilter.amountMicros,
value.amountMicros,
);
} else if (shouldMatchCurrencyCodeFilter) {
return isMatchingCurrencyCodeFilter(
currencyFilter.currencyCode,
value.currencyCode,
);
}
throw new Error(
`Unexpected filter for currency : ${JSON.stringify(currencyFilter)}`,
);
};

View File

@ -8,7 +8,7 @@ export const isRecordFilterConsideredEmpty = (
const { value, operand } = recordFilter; const { value, operand } = recordFilter;
if ( if (
(!isDefined(value) || value === '') && (!isDefined(value) || value === '' || value === '[]') &&
![ ![
RecordFilterOperand.IsEmpty, RecordFilterOperand.IsEmpty,
RecordFilterOperand.IsNotEmpty, RecordFilterOperand.IsNotEmpty,

View File

@ -319,7 +319,7 @@ export const isRecordMatchingFilter = ({
case FieldMetadataType.CURRENCY: { case FieldMetadataType.CURRENCY: {
return isMatchingCurrencyFilter({ return isMatchingCurrencyFilter({
currencyFilter: filterValue as CurrencyFilter, currencyFilter: filterValue as CurrencyFilter,
value: record[filterKey].amountMicros, value: record[filterKey],
}); });
} }
case FieldMetadataType.ACTOR: { case FieldMetadataType.ACTOR: {

View File

@ -11,6 +11,7 @@ import { useSelectFilterUsedInDropdown } from '@/object-record/object-filter-dro
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious'; import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
@ -103,8 +104,14 @@ export const useHandleToggleColumnFilter = ({
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type); const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
const defaultSubFieldName =
getDefaultSubFieldNameForCompositeFilterableFieldType(
fieldMetadataItem.type,
);
const availableOperandsForFilter = getRecordFilterOperands({ const availableOperandsForFilter = getRecordFilterOperands({
filterType, filterType,
subFieldName: defaultSubFieldName,
}); });
const defaultOperand = availableOperandsForFilter[0]; const defaultOperand = availableOperandsForFilter[0];
@ -117,6 +124,7 @@ export const useHandleToggleColumnFilter = ({
label: fieldMetadataItem.label, label: fieldMetadataItem.label,
type: filterType, type: filterType,
value: '', value: '',
subFieldName: defaultSubFieldName,
}; };
upsertRecordFilter(newFilter); upsertRecordFilter(newFilter);

View File

@ -1,6 +1,6 @@
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { import {
RecordFilter, RecordFilter,
@ -25,7 +25,7 @@ export const buildValueFromFilter = ({
currentWorkspaceMember?: CurrentWorkspaceMember; currentWorkspaceMember?: CurrentWorkspaceMember;
label?: string; label?: string;
}) => { }) => {
if (isCompositeField(filter.type)) { if (isCompositeFieldType(filter.type)) {
return; return;
} }

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { StyledMultipleSelectDropdownAvatarChip } from '@/object-record/select/components/StyledMultipleSelectDropdownAvatarChip'; import { StyledMultipleSelectDropdownAvatarChip } from '@/object-record/select/components/StyledMultipleSelectDropdownAvatarChip';
@ -57,19 +56,10 @@ export const MultipleSelectDropdown = ({
); );
}; };
const [itemsInDropdown, setItemInDropdown] = useState([ const itemsInDropdown = [
...(filteredSelectedItems ?? []), ...(filteredSelectedItems ?? []),
...(itemsToSelect ?? []), ...(itemsToSelect ?? []),
]); ];
useEffect(() => {
if (!loadingItems) {
setItemInDropdown([
...(filteredSelectedItems ?? []),
...(itemsToSelect ?? []),
]);
}
}, [itemsToSelect, filteredSelectedItems, loadingItems]);
useScopedHotkeys( useScopedHotkeys(
[Key.Escape], [Key.Escape],

View File

@ -0,0 +1,10 @@
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { Currency } from '@/ui/input/components/internal/types/Currency';
export const CURRENCIES: Currency[] = Object.entries(
SETTINGS_FIELD_CURRENCY_CODES,
).map(([key, { Icon, label }]) => ({
value: key,
Icon,
label: `${label} (${key})`,
}));

View File

@ -40,8 +40,8 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
[FieldMetadataType.CURRENCY]: { [FieldMetadataType.CURRENCY]: {
label: 'Currency', label: 'Currency',
Icon: IllustrationIconCurrency, Icon: IllustrationIconCurrency,
subFields: ['amountMicros'], subFields: ['amountMicros', 'currencyCode'],
filterableSubFields: ['amountMicros'], filterableSubFields: ['amountMicros', 'currencyCode'],
labelBySubField: { labelBySubField: {
amountMicros: 'Amount', amountMicros: 'Amount',
currencyCode: 'Currency', currencyCode: 'Currency',

View File

@ -4,10 +4,9 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { currencyFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/currencyFieldDefaultValueSchema'; import { currencyFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/currencyFieldDefaultValueSchema';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes'; import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fields/forms/currency/hooks/useCurrencySettingsFormInitialValues'; import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fields/forms/currency/hooks/useCurrencySettingsFormInitialValues';
import { Select } from '@/ui/input/components/Select'; import { Select } from '@/ui/input/components/Select';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { IconCurrencyDollar } from 'twenty-ui/display'; import { IconCurrencyDollar } from 'twenty-ui/display';
@ -24,14 +23,6 @@ type SettingsDataModelFieldCurrencyFormProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue'>; fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue'>;
}; };
const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([value, { label, Icon }]) => ({
label,
value: applySimpleQuotesToString(value),
Icon,
}),
);
export const SettingsDataModelFieldCurrencyForm = ({ export const SettingsDataModelFieldCurrencyForm = ({
disabled, disabled,
fieldMetadataItem, fieldMetadataItem,
@ -67,7 +58,7 @@ export const SettingsDataModelFieldCurrencyForm = ({
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
dropdownId="object-field-default-value-select-currency" dropdownId="object-field-default-value-select-currency"
options={OPTIONS} options={CURRENCIES}
selectSizeVariant="small" selectSizeVariant="small"
withSearchInput={true} withSearchInput={true}
/> />

View File

@ -1,5 +1,5 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey'; import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@ -94,7 +94,7 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({
} }
LeftIcon={getIcon(field.icon)} LeftIcon={getIcon(field.icon)}
text={field.label} text={field.label}
hasSubMenu={isCompositeField(field.type)} hasSubMenu={isCompositeFieldType(field.type)}
/> />
))} ))}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>

View File

@ -1,5 +1,5 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@ -48,7 +48,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
onBack(); onBack();
}; };
if (!isCompositeField(fieldMetadataItem.type)) { if (!isCompositeFieldType(fieldMetadataItem.type)) {
return <></>; return <></>;
} }

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { ReadonlyDeep } from 'type-fest'; import { ReadonlyDeep } from 'type-fest';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
import { MatchColumnSelectFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent'; import { MatchColumnSelectFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent';
import { MatchColumnSelectSubFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent'; import { MatchColumnSelectSubFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent';
@ -40,7 +40,7 @@ export const MatchColumnToFieldSelect = ({
) => { ) => {
setSelectedFieldMetadataItem(selectedFieldMetadataItem); setSelectedFieldMetadataItem(selectedFieldMetadataItem);
if (!isCompositeField(selectedFieldMetadataItem.type)) { if (!isCompositeFieldType(selectedFieldMetadataItem.type)) {
const correspondingOption = options.find( const correspondingOption = options.find(
(option) => option.value === selectedFieldMetadataItem.name, (option) => option.value === selectedFieldMetadataItem.name,
); );
@ -100,11 +100,9 @@ export const MatchColumnToFieldSelect = ({
(option) => option.value === DO_NOT_IMPORT_OPTION_KEY, (option) => option.value === DO_NOT_IMPORT_OPTION_KEY,
); );
const shouldDisplaySubFieldMetadataItemSelect = isDefined( const shouldDisplaySubFieldMetadataItemSelect =
selectedFieldMetadataItem?.type, isDefined(selectedFieldMetadataItem?.type) &&
) isCompositeFieldType(selectedFieldMetadataItem?.type);
? isCompositeField(selectedFieldMetadataItem?.type)
: false;
return ( return (
<Dropdown <Dropdown

View File

@ -1,10 +1,11 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes'; import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { CurrencyPickerDropdownButton } from '@/ui/input/components/internal/currency/components/CurrencyPickerDropdownButton'; import { CurrencyPickerDropdownButton } from '@/ui/input/components/internal/currency/components/CurrencyPickerDropdownButton';
import { Currency } from '@/ui/input/components/internal/types/Currency';
import { IMaskInput } from 'react-imask'; import { IMaskInput } from 'react-imask';
import { IconComponent } from 'twenty-ui/display'; import { IconComponent } from 'twenty-ui/display';
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme'; import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
@ -50,12 +51,6 @@ export type CurrencyInputProps = {
hotkeyScope: string; hotkeyScope: string;
}; };
type Currency = {
label: string;
value: string;
Icon: any;
};
export const CurrencyInput = ({ export const CurrencyInput = ({
autoFocus, autoFocus,
value, value,
@ -96,19 +91,7 @@ export const CurrencyInput = ({
hotkeyScope, hotkeyScope,
}); });
const currencies = useMemo<Currency[]>( const currency = CURRENCIES.find(({ value }) => value === currencyCode);
() =>
Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([key, { Icon, label }]) => ({
value: key,
Icon,
label,
}),
),
[],
);
const currency = currencies.find(({ value }) => value === currencyCode);
useEffect(() => { useEffect(() => {
setInternalText(value); setInternalText(value);
@ -119,9 +102,8 @@ export const CurrencyInput = ({
return ( return (
<StyledContainer ref={wrapperRef}> <StyledContainer ref={wrapperRef}>
<CurrencyPickerDropdownButton <CurrencyPickerDropdownButton
valueCode={currency?.value ?? ''} selectedCurrencyCode={currency?.value ?? ''}
onChange={handleCurrencyChange} onChange={handleCurrencyChange}
currencies={currencies}
/> />
<StyledIcon> <StyledIcon>
{Icon && ( {Icon && (

View File

@ -7,8 +7,10 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { CurrencyPickerHotkeyScope } from '../types/CurrencyPickerHotkeyScope'; import { CurrencyPickerHotkeyScope } from '../types/CurrencyPickerHotkeyScope';
import { CurrencyPickerDropdownSelect } from './CurrencyPickerDropdownSelect'; import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { Currency } from '@/ui/input/components/internal/types/Currency';
import { IconChevronDown } from 'twenty-ui/display'; import { IconChevronDown } from 'twenty-ui/display';
import { CurrencyPickerDropdownSelect } from './CurrencyPickerDropdownSelect';
const StyledDropdownButtonContainer = styled.div` const StyledDropdownButtonContainer = styled.div`
align-items: center; align-items: center;
@ -41,20 +43,12 @@ const StyledIconContainer = styled.div`
} }
`; `;
export type Currency = {
label: string;
value: string;
Icon: any;
};
export const CurrencyPickerDropdownButton = ({ export const CurrencyPickerDropdownButton = ({
valueCode, selectedCurrencyCode,
onChange, onChange,
currencies,
}: { }: {
valueCode: string; selectedCurrencyCode: string;
onChange: (currency: Currency) => void; onChange: (currency: Currency) => void;
currencies: Currency[];
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@ -67,7 +61,9 @@ export const CurrencyPickerDropdownButton = ({
closeDropdown(); closeDropdown();
}; };
const currency = currencies.find(({ value }) => value === valueCode); const currency = CURRENCIES.find(
({ value }) => value === selectedCurrencyCode,
);
const currencyCode = currency?.value ?? CurrencyCode.USD; const currencyCode = currency?.value ?? CurrencyCode.USD;
@ -85,7 +81,6 @@ export const CurrencyPickerDropdownButton = ({
} }
dropdownComponents={ dropdownComponents={
<CurrencyPickerDropdownSelect <CurrencyPickerDropdownSelect
currencies={currencies}
selectedCurrency={currency} selectedCurrency={currency}
onChange={handleChange} onChange={handleChange}
/> />

View File

@ -4,15 +4,15 @@ import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { Currency } from './CurrencyPickerDropdownButton';
import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { Currency } from '@/ui/input/components/internal/types/Currency';
import { MenuItem, MenuItemSelectAvatar } from 'twenty-ui/navigation'; import { MenuItem, MenuItemSelectAvatar } from 'twenty-ui/navigation';
export const CurrencyPickerDropdownSelect = ({ export const CurrencyPickerDropdownSelect = ({
currencies,
selectedCurrency, selectedCurrency,
onChange, onChange,
}: { }: {
currencies: Currency[];
selectedCurrency?: Currency; selectedCurrency?: Currency;
onChange: (currency: Currency) => void; onChange: (currency: Currency) => void;
}) => { }) => {
@ -20,14 +20,14 @@ export const CurrencyPickerDropdownSelect = ({
const filteredCurrencies = useMemo( const filteredCurrencies = useMemo(
() => () =>
currencies.filter( CURRENCIES.filter(
({ value, label }) => ({ value, label }) =>
value value
.toLocaleLowerCase() .toLocaleLowerCase()
.includes(searchFilter.toLocaleLowerCase()) || .includes(searchFilter.toLocaleLowerCase()) ||
label.toLocaleLowerCase().includes(searchFilter.toLocaleLowerCase()), label.toLocaleLowerCase().includes(searchFilter.toLocaleLowerCase()),
), ),
[currencies, searchFilter], [searchFilter],
); );
return ( return (
@ -49,7 +49,7 @@ export const CurrencyPickerDropdownSelect = ({
key={selectedCurrency.value} key={selectedCurrency.value}
selected={true} selected={true}
onClick={() => onChange(selectedCurrency)} onClick={() => onChange(selectedCurrency)}
text={`${selectedCurrency.label} (${selectedCurrency.value})`} text={selectedCurrency.label}
/> />
)} )}
{filteredCurrencies.map((item) => {filteredCurrencies.map((item) =>

View File

@ -0,0 +1,5 @@
export type Currency = {
label: string;
value: string;
Icon: any;
};

View File

@ -1,9 +1,10 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { getOperandLabelShort } from '@/object-record/object-filter-dropdown/utils/getOperandLabel'; import { getOperandLabelShort } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFilterOperandExpectingValue } from '@/object-record/object-filter-dropdown/utils/isFilterOperandExpectingValue';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand';
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
import { isValidSubFieldName } from '@/settings/data-model/utils/isValidSubFieldName'; import { isValidSubFieldName } from '@/settings/data-model/utils/isValidSubFieldName';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip'; import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
@ -27,11 +28,12 @@ export const EditableFilterChip = ({
const FieldMetadataItemIcon = getIcon(fieldMetadataItem.icon); const FieldMetadataItemIcon = getIcon(fieldMetadataItem.icon);
const operandLabelShort = getOperandLabelShort(recordFilter.operand); const operandLabelShort = getOperandLabelShort(recordFilter.operand);
const operandIsEmptiness = isEmptinessOperand(recordFilter.operand);
const recordFilterSubFieldName = recordFilter.subFieldName; const recordFilterSubFieldName = recordFilter.subFieldName;
const subFieldLabel = const subFieldLabel =
isCompositeField(fieldMetadataItem.type) && isCompositeFieldType(fieldMetadataItem.type) &&
isNonEmptyString(recordFilterSubFieldName) && isNonEmptyString(recordFilterSubFieldName) &&
isValidSubFieldName(recordFilterSubFieldName) isValidSubFieldName(recordFilterSubFieldName)
? getCompositeSubFieldLabel( ? getCompositeSubFieldLabel(
@ -44,11 +46,9 @@ export const EditableFilterChip = ({
? `${recordFilter.label} / ${subFieldLabel}` ? `${recordFilter.label} / ${subFieldLabel}`
: recordFilter.label; : recordFilter.label;
const shouldDisplayOperandLabelShort = const recordFilterIsEmpty = isRecordFilterConsideredEmpty(recordFilter);
isNonEmptyString(recordFilter.value) ||
!isFilterOperandExpectingValue(recordFilter.operand);
const labelKey = `${fieldNameLabel}${shouldDisplayOperandLabelShort ? operandLabelShort : ''}`; const labelKey = `${fieldNameLabel}${!operandIsEmptiness && !recordFilterIsEmpty ? operandLabelShort : operandIsEmptiness ? ` ${operandLabelShort}` : ''}`;
return ( return (
<SortOrFilterChip <SortOrFilterChip