Sub-field filtering on ADDRESS type (#11912)

This PR adds what's needed to filter on the ADDRESS sub-fields, notably
the country sub-field, that requires a country multi select component,
which was created in this PR (ObjectFilterDropdownCountrySelect)

This PR refactors the common logic between advanced filter dropdown
field selection logic and view bar filter dropdown field selection
logic, notably in useFilterDropdownSelectableFieldMetadataItems.

There are now new components to identify clearly what's tied to view bar
or advanced filter, it could be further simplified or factorized, but as
it is right now, it's simple enough to be maintained easily even if a
little bit too verbose, which is often the best trade-off we should aim
for.

Improvements : 
- Added the CompositeFieldSubFieldName where needed
- Fixes bug in advanced filter dropdown input
- Fixes dropdown content width bug in advanced filter dropdown input
- Fixes a bug when inputing a Currency filter without a sub-field in
view bar filter dropdown
- Used DropdownMenuSearchInput instead of a custom StyledInput which was
doing exactly the same thing
- Factorized the state setting logic in
useSetAdvancedFilterDropdownStates in an anonymous function
setAdvancedFilterDropdownStates
- Created useSelectFilterFromViewBarFilterDropdown hook to have a more
meaningful and clear logic to abstract what happens when we select a
field to filter in the view bard field select dropdown
- Fixes a bug with advanced filter operand dropdown select which wasn't
modifying the current record filter and creating a stale state.

Fixes https://github.com/twentyhq/core-team-issues/issues/612
This commit is contained in:
Lucas Bordeau
2025-05-09 11:32:46 +02:00
committed by GitHub
parent 97d44d13ba
commit afea017c12
37 changed files with 932 additions and 210 deletions

View File

@ -0,0 +1,81 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { AdvancedFilterDropdownFieldSelectMenuItem } from '@/object-record/advanced-filter/components/AdvancedFilterDropdownFieldSelectMenuItem';
import { FILTER_FIELD_LIST_ID } from '@/object-record/object-filter-dropdown/constants/FilterFieldListId';
import { useFilterDropdownSelectableFieldMetadataItems } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdownSelectableFieldMetadataItems';
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useLingui } from '@lingui/react/macro';
export const AdvancedFilterDropdownFieldSelectMenu = () => {
const setObjectFilterDropdownSearchInput = useSetRecoilComponentStateV2(
objectFilterDropdownSearchInputComponentState,
);
const objectFilterDropdownSearchInput = useRecoilComponentValueV2(
objectFilterDropdownSearchInputComponentState,
);
const {
selectableHiddenFieldMetadataItems,
selectableVisibleFieldMetadataItems,
} = useFilterDropdownSelectableFieldMetadataItems();
const shouldShowSeparator =
selectableVisibleFieldMetadataItems.length > 0 &&
selectableHiddenFieldMetadataItems.length > 0;
const { t } = useLingui();
const selectableFieldMetadataItemIds = [
...selectableVisibleFieldMetadataItems.map(
(fieldMetadataItem) => fieldMetadataItem.id,
),
...selectableHiddenFieldMetadataItems.map(
(fieldMetadataItem) => fieldMetadataItem.id,
),
];
return (
<>
<DropdownMenuSearchInput
value={objectFilterDropdownSearchInput}
autoFocus
placeholder={t`Search fields`}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setObjectFilterDropdownSearchInput(event.target.value)
}
/>
<SelectableList
hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton}
selectableItemIdArray={selectableFieldMetadataItemIds}
selectableListInstanceId={FILTER_FIELD_LIST_ID}
>
<DropdownMenuItemsContainer>
{selectableVisibleFieldMetadataItems.map(
(visibleFieldMetadataItem) => (
<AdvancedFilterDropdownFieldSelectMenuItem
key={visibleFieldMetadataItem.id}
fieldMetadataItemToSelect={visibleFieldMetadataItem}
/>
),
)}
{shouldShowSeparator && <DropdownMenuSeparator />}
{selectableHiddenFieldMetadataItems.map((hiddenFieldMetadataItem) => (
<AdvancedFilterDropdownFieldSelectMenuItem
key={hiddenFieldMetadataItem.id}
fieldMetadataItemToSelect={hiddenFieldMetadataItem}
/>
))}
</DropdownMenuItemsContainer>
</SelectableList>
</>
);
};

View File

@ -26,13 +26,13 @@ import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export type ObjectFilterDropdownFilterSelectMenuItemProps = {
export type AdvancedFilterDropdownFieldSelectMenuItemProps = {
fieldMetadataItemToSelect: FieldMetadataItem;
};
export const ObjectFilterDropdownFilterSelectMenuItem = ({
export const AdvancedFilterDropdownFieldSelectMenuItem = ({
fieldMetadataItemToSelect,
}: ObjectFilterDropdownFilterSelectMenuItemProps) => {
}: AdvancedFilterDropdownFieldSelectMenuItemProps) => {
const setFieldMetadataItemIdUsedInDropdown = useSetRecoilComponentStateV2(
fieldMetadataItemIdUsedInDropdownComponentState,
);

View File

@ -5,7 +5,9 @@ import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-d
import { ObjectFilterDropdownSourceSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { AdvancedFilterDropdownTextInput } from '@/object-record/advanced-filter/components/AdvancedFilterDropdownTextInput';
import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect';
import { ObjectFilterDropdownCountrySelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownCountrySelect';
import { ObjectFilterDropdownCurrencySelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect';
import { ObjectFilterDropdownDateInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput';
import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput';
@ -39,6 +41,12 @@ export const AdvancedFilterDropdownFilterInput = ({
return (
<>
{filterType === 'ADDRESS' &&
(subFieldNameUsedInDropdown === 'addressCountry' ? (
<ObjectFilterDropdownCountrySelect />
) : (
<AdvancedFilterDropdownTextInput recordFilter={recordFilter} />
))}
{filterType === 'RATING' && <ObjectFilterDropdownRatingInput />}
{DATE_FILTER_TYPES.includes(filterType) && (
<ObjectFilterDropdownDateInput />

View File

@ -1,6 +1,5 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFieldSelect';
import { FILTER_FIELD_LIST_ID } from '@/object-record/object-filter-dropdown/constants/FilterFieldListId';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
@ -21,6 +20,7 @@ import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-re
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { isCompositeTypeFilterableByAnySubField } from '@/object-record/record-filter/utils/isCompositeTypeFilterableByAnySubField';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -30,12 +30,13 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { StyledInput } from '@/views/components/ViewBarFilterDropdownFieldSelectMenu';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconApps, IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export const ObjectFilterDropdownSubFieldSelect = () => {
export const AdvancedFilterDropdownSubFieldSelectMenu = () => {
const [searchText, setSearchText] = useState('');
const { getIcon } = useIcons();
@ -87,7 +88,7 @@ export const ObjectFilterDropdownSubFieldSelect = () => {
const handleSelectFilter = (
fieldMetadataItem: FieldMetadataItem | null | undefined,
subFieldName?: string | null | undefined,
subFieldName?: CompositeFieldSubFieldName | null | undefined,
) => {
if (!isDefined(fieldMetadataItem)) {
return;

View File

@ -1,10 +1,7 @@
import { useGetFieldMetadataItemById } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
import { useApplyObjectFilterDropdownOperand } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand';
import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { SelectControl } from '@/ui/input/components/SelectControl';
@ -41,42 +38,17 @@ export const AdvancedFilterRecordFilterOperandSelect = ({
(recordFilter) => recordFilter.id === recordFilterId,
);
const { getFieldMetadataItemById } = useGetFieldMetadataItemById();
const isDisabled = !filter?.fieldMetadataId;
const { closeDropdown } = useDropdown(dropdownId);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { applyObjectFilterDropdownOperand } =
useApplyObjectFilterDropdownOperand();
const handleOperandChange = (operand: ViewFilterOperand) => {
closeDropdown();
if (!filter) {
throw new Error('Filter is not defined');
}
const fieldMetadataItem = getFieldMetadataItemById(filter.fieldMetadataId);
if (!isDefined(fieldMetadataItem)) {
throw new Error('Field metadata item is not defined');
}
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
const { value, displayValue } = getInitialFilterValue(
filterType,
operand,
filter.value,
filter.displayValue,
);
upsertRecordFilter({
...filter,
operand,
value,
displayValue,
});
applyObjectFilterDropdownOperand(operand);
};
const filterType = filter?.type;

View File

@ -15,6 +15,7 @@ import { ICON_NAME_BY_SUB_FIELD } from '@/object-record/record-filter/constants/
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 { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
@ -57,7 +58,7 @@ export const AdvancedFilterSubFieldSelectMenu = ({
const handleSelectFilter = (
selectedFieldMetadataItem: FieldMetadataItem | null | undefined,
subFieldName?: string | null | undefined,
subFieldName?: CompositeFieldSubFieldName | null | undefined,
) => {
if (!isDefined(selectedFieldMetadataItem)) {
return;

View File

@ -7,6 +7,7 @@ import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/consta
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
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';
@ -36,6 +37,10 @@ export const AdvancedFilterValueInput = ({
currentRecordFiltersComponentState,
);
const subFieldNameUsedInDropdown = useRecoilComponentValueV2(
subFieldNameUsedInDropdownComponentState,
);
const recordFilter = currentRecordFilters.find(
(recordFilter) => recordFilter.id === recordFilterId,
);
@ -86,7 +91,9 @@ export const AdvancedFilterValueInput = ({
FieldMetadataType.CURRENCY,
'amountMicros',
recordFilter.subFieldName,
);
) ||
(filterType === 'ADDRESS' &&
subFieldNameUsedInDropdown !== 'addressCountry');
return (
<StyledValueDropdownContainer>

View File

@ -11,6 +11,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -20,7 +21,7 @@ import { isDefined } from 'twenty-shared/utils';
type SelectFilterParams = {
fieldMetadataItemId: string;
recordFilterId: string;
subFieldName?: string | null | undefined;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
};
export const useSelectFieldUsedInAdvancedFilterDropdown = () => {

View File

@ -2,8 +2,10 @@ import { rootLevelRecordFilterGroupComponentSelector } from '@/object-record/adv
import { getAdvancedFilterObjectFilterDropdownComponentInstanceId } from '@/object-record/advanced-filter/utils/getAdvancedFilterObjectFilterDropdownComponentInstanceId';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilCallback } from 'recoil';
@ -28,26 +30,42 @@ export const useSetAdvancedFilterDropdownStates = () => {
recordFilter.recordFilterGroupId === rootLevelRecordFilterGroup?.id,
);
for (const rootLevelRecordFilter of rootLevelRecordFilters) {
const setAdvancedFilterStatesForRecordFilter = (
recordFilter: RecordFilter,
) => {
set(
objectFilterDropdownCurrentRecordFilterComponentState.atomFamily({
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
rootLevelRecordFilter.id,
recordFilter.id,
),
}),
rootLevelRecordFilter,
recordFilter,
);
set(
fieldMetadataItemIdUsedInDropdownComponentState.atomFamily({
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
rootLevelRecordFilter.id,
recordFilter.id,
),
}),
rootLevelRecordFilter.fieldMetadataId,
recordFilter.fieldMetadataId,
);
set(
subFieldNameUsedInDropdownComponentState.atomFamily({
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
recordFilter.id,
),
}),
recordFilter.subFieldName,
);
};
for (const rootLevelRecordFilter of rootLevelRecordFilters) {
setAdvancedFilterStatesForRecordFilter(rootLevelRecordFilter);
}
const childRecordFilterGroups = currentRecordFilterGroups.filter(
@ -63,25 +81,7 @@ export const useSetAdvancedFilterDropdownStates = () => {
);
for (const recordFilterInThisGroup of recordFiltersInThisGroup) {
set(
objectFilterDropdownCurrentRecordFilterComponentState.atomFamily({
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
recordFilterInThisGroup.id,
),
}),
recordFilterInThisGroup,
);
set(
fieldMetadataItemIdUsedInDropdownComponentState.atomFamily({
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
recordFilterInThisGroup.id,
),
}),
recordFilterInThisGroup.fieldMetadataId,
);
setAdvancedFilterStatesForRecordFilter(recordFilterInThisGroup);
}
}
},

View File

@ -0,0 +1,146 @@
import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { getCountryFlagMenuItemAvatar } from '@/object-record/object-filter-dropdown/utils/getCountryFlagMenuItemAvatar';
import { turnCountryIntoSelectableItem } from '@/object-record/object-filter-dropdown/utils/turnCountryIntoSelectableItem';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
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 { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { ChangeEvent, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { MenuItem, MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
export const EMPTY_FILTER_VALUE = '[]';
export const MAX_ITEMS_TO_DISPLAY = 5;
type ObjectFilterDropdownCountrySelectProps = {
dropdownWidth?: number;
};
export const ObjectFilterDropdownCountrySelect = ({
dropdownWidth,
}: ObjectFilterDropdownCountrySelectProps) => {
const [searchText, setSearchText] = useState('');
const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValueV2(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const { applyObjectFilterDropdownFilterValue } =
useApplyObjectFilterDropdownFilterValue();
const fieldMetadataItemUsedInFilterDropdown = useRecoilComponentValueV2(
fieldMetadataItemUsedInDropdownComponentSelector,
);
const countries = useCountries();
const countriesAsSelectableItems = countries.map(
turnCountryIntoSelectableItem,
);
const selectedCountryNames = isNonEmptyString(
objectFilterDropdownCurrentRecordFilter?.value,
)
? (JSON.parse(objectFilterDropdownCurrentRecordFilter.value) as string[]) // TODO: replace by a safe parse
: [];
const filteredSelectableItems = countriesAsSelectableItems.filter(
(selectableItem) =>
selectableItem.name.toLowerCase().includes(searchText.toLowerCase()) &&
!selectedCountryNames.includes(selectableItem.name),
);
const filteredSelectedItems = countriesAsSelectableItems.filter(
(selectableItem) =>
selectableItem.name.toLowerCase().includes(searchText.toLowerCase()) &&
selectedCountryNames.includes(selectableItem.name),
);
const handleMultipleItemSelectChange = (
itemToSelect: SelectableItem,
newSelectedValue: boolean,
) => {
const newSelectedItemNames = newSelectedValue
? [...selectedCountryNames, itemToSelect.name]
: selectedCountryNames.filter((name) => name !== itemToSelect.name);
if (!isDefined(fieldMetadataItemUsedInFilterDropdown)) {
throw new Error(
'Field metadata item used in filter dropdown should be defined',
);
}
const selectedItemNames = countriesAsSelectableItems
.filter((option) => newSelectedItemNames.includes(option.name))
.map((option) => option.name);
const filterDisplayValue =
selectedItemNames.length > MAX_ITEMS_TO_DISPLAY
? `${selectedItemNames.length} countries`
: selectedItemNames.join(', ');
const newFilterValue =
newSelectedItemNames.length > 0
? JSON.stringify(selectedItemNames)
: EMPTY_FILTER_VALUE;
applyObjectFilterDropdownFilterValue(newFilterValue, filterDisplayValue);
};
const showNoResult =
filteredSelectableItems.length === 0 &&
filteredSelectedItems.length === 0 &&
searchText !== '';
const { t } = useLingui();
return (
<>
<DropdownMenuSearchInput
autoFocus
type="text"
value={searchText}
placeholder={t`Search country`}
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);
}}
text={item.name}
avatar={getCountryFlagMenuItemAvatar(item.name, countries)}
/>
);
})}
{filteredSelectableItems?.map((item) => {
return (
<MenuItemMultiSelectAvatar
key={item.id}
selected={false}
onSelectChange={(newCheckedValue) => {
handleMultipleItemSelectChange(item, newCheckedValue);
}}
text={item.name}
avatar={getCountryFlagMenuItemAvatar(item.name, countries)}
/>
);
})}
{showNoResult && <MenuItem text={t`No results`} />}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -60,6 +60,8 @@ export const ObjectFilterDropdownCurrencySelect = ({
selectedCurrencies.includes(selectableItem.id),
);
const { t } = useLingui();
const handleMultipleItemSelectChange = (
itemToSelect: SelectableItem,
newSelectedValue: boolean,
@ -78,9 +80,11 @@ export const ObjectFilterDropdownCurrencySelect = ({
.filter((option) => newSelectedItemIds.includes(option.id))
.map((option) => option.name);
const currenciesLabel = t`currencies`;
const filterDisplayValue =
selectedItemNames.length > MAX_ITEMS_TO_DISPLAY
? `${selectedItemNames.length} currencies`
? `${selectedItemNames.length} ${currenciesLabel}`
: selectedItemNames.join(', ');
const newFilterValue =
@ -96,8 +100,6 @@ export const ObjectFilterDropdownCurrencySelect = ({
filteredSelectedItems.length === 0 &&
searchText !== '';
const { t } = useLingui();
return (
<>
<DropdownMenuSearchInput

View File

@ -75,6 +75,8 @@ export const ObjectFilterDropdownFilterInput = ({
subFieldNameUsedInDropdown,
);
const isNotASubFieldFilter = !isDefined(subFieldNameUsedInDropdown);
return (
<>
{isConfigurable && selectedOperandInDropdown && (
@ -99,7 +101,7 @@ export const ObjectFilterDropdownFilterInput = ({
</>
)}
{filterType === 'ACTOR' &&
(isActorSourceCompositeFilter ? (
(isActorSourceCompositeFilter || isNotASubFieldFilter ? (
<>
<ObjectFilterDropdownSourceSelect />
</>
@ -108,6 +110,14 @@ export const ObjectFilterDropdownFilterInput = ({
<ObjectFilterDropdownTextInput />
</>
))}
{filterType === 'ADDRESS' &&
(isNotASubFieldFilter ? (
<>
<ObjectFilterDropdownTextInput />
</>
) : (
<></>
))}
{filterType === 'CURRENCY' &&
(isExpectedSubFieldName(
FieldMetadataType.CURRENCY,
@ -126,7 +136,7 @@ export const ObjectFilterDropdownFilterInput = ({
<ObjectFilterDropdownNumberInput />
</>
) : (
<></>
<ObjectFilterDropdownNumberInput />
))}
{['SELECT', 'MULTI_SELECT'].includes(filterType) && (
<>

View File

@ -5,7 +5,6 @@ export const TEXT_FILTER_TYPES = [
'FULL_NAME',
'LINK',
'LINKS',
'ADDRESS',
'ARRAY',
'RAW_JSON',
];

View File

@ -0,0 +1,56 @@
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const useFilterDropdownSelectableFieldMetadataItems = () => {
const { recordIndexId } = useRecordIndexContextOrThrow();
const objectFilterDropdownSearchInput = useRecoilComponentValueV2(
objectFilterDropdownSearchInputComponentState,
);
const { filterableFieldMetadataItems } =
useFilterableFieldMetadataItemsInRecordIndexContext();
const visibleTableColumns = useRecoilComponentValueV2(
visibleTableColumnsComponentSelector,
recordIndexId,
);
const visibleColumnsIds = visibleTableColumns.map(
(column) => column.fieldMetadataId,
);
const filteredSearchInputFieldMetadataItems =
filterableFieldMetadataItems.filter((fieldMetadataItem) =>
fieldMetadataItem.label
.toLocaleLowerCase()
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase()),
);
const selectableVisibleFieldMetadataItems =
filteredSearchInputFieldMetadataItems
.sort((a, b) => {
return (
visibleColumnsIds.indexOf(a.id) - visibleColumnsIds.indexOf(b.id)
);
})
.filter((fieldMetadataItem) =>
visibleColumnsIds.includes(fieldMetadataItem.id),
);
const selectableHiddenFieldMetadataItems =
filteredSearchInputFieldMetadataItems
.sort((a, b) => a.label.localeCompare(b.label))
.filter(
(fieldMetadataItem) =>
!visibleColumnsIds.includes(fieldMetadataItem.id),
);
return {
selectableVisibleFieldMetadataItems,
selectableHiddenFieldMetadataItems,
};
};

View File

@ -0,0 +1,84 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isDefined } from 'twenty-shared/utils';
export const useSelectFilterFromViewBarFilterDropdown = () => {
const setFieldMetadataItemIdUsedInDropdown = useSetRecoilComponentStateV2(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const [, setObjectFilterDropdownFilterIsSelected] = useRecoilComponentStateV2(
objectFilterDropdownFilterIsSelectedComponentState,
);
const setSelectedOperandInDropdown = useSetRecoilComponentStateV2(
selectedOperandInDropdownComponentState,
);
const setHotkeyScope = useSetHotkeyScope();
const currentRecordFilters = useRecoilComponentValueV2(
currentRecordFiltersComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter =
useSetRecoilComponentStateV2(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const selectFilterFromViewBarFilterDropdown = (
fieldMetadataItem: FieldMetadataItem,
) => {
setFieldMetadataItemIdUsedInDropdown(fieldMetadataItem.id);
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
if (filterType === 'RELATION' || filterType === 'SELECT') {
setHotkeyScope(SingleRecordPickerHotkeyScope.SingleRecordPicker);
}
const defaultOperand = getRecordFilterOperands({
filterType,
})[0];
setObjectFilterDropdownFilterIsSelected(true);
const duplicateFilterInCurrentRecordFilters =
findDuplicateRecordFilterInNonAdvancedRecordFilters({
recordFilters: currentRecordFilters,
fieldMetadataItemId: fieldMetadataItem.id,
});
const filterIsAlreadyInCurrentRecordFilters = isDefined(
duplicateFilterInCurrentRecordFilters,
);
if (filterIsAlreadyInCurrentRecordFilters) {
setObjectFilterDropdownCurrentRecordFilter(
duplicateFilterInCurrentRecordFilters,
);
setSelectedOperandInDropdown(
duplicateFilterInCurrentRecordFilters.operand,
);
} else {
setSelectedOperandInDropdown(defaultOperand);
}
};
return {
selectFilterFromViewBarFilterDropdown,
};
};

View File

@ -1,8 +1,9 @@
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const subFieldNameUsedInDropdownComponentState = createComponentStateV2<
string | null | undefined
CompositeFieldSubFieldName | null | undefined
>({
key: 'subFieldNameUsedInDropdownComponentState',
defaultValue: null,

View File

@ -0,0 +1,16 @@
import { Country } from '@/ui/input/components/internal/types/Country';
export const getCountryFlagMenuItemAvatar = (
countryName: string,
countries: Country[],
): React.ReactNode => {
const country = countries.find(
(country) => country.countryName === countryName,
);
if (!country) {
return <div style={{ width: 20 }} />;
}
return country.Flag({ width: 20 });
};

View File

@ -0,0 +1,10 @@
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { Country } from '@/ui/input/components/internal/types/Country';
export const turnCountryIntoSelectableItem = (
country: Country,
): SelectableItem => ({
id: country.countryCode,
name: `${country.countryName}`,
isSelected: false,
});

View File

@ -3,7 +3,6 @@ import { useEffect } from 'react';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFieldSelect';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { useSearchRecordGroupField } from '@/object-record/object-options-dropdown/hooks/useSearchRecordGroupField';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
@ -13,6 +12,7 @@ import { SettingsPath } from '@/types/SettingsPath';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
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 { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -118,7 +118,7 @@ export const ObjectOptionsDropdownRecordGroupFieldsContent = () => {
>
{t`Group by`}
</DropdownMenuHeader>
<StyledInput
<DropdownMenuSearchInput
autoFocus
value={recordGroupFieldSearchInput}
placeholder={t`Search fields`}

View File

@ -1,5 +1,6 @@
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
export type RecordFilter = {
@ -13,7 +14,7 @@ export type RecordFilter = {
operand: ViewFilterOperand;
positionInRecordFilterGroup?: number | null;
label: string;
subFieldName?: string | null | undefined;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
};
export type RecordFilterToRecordInputOperand<T extends FilterableFieldType> =

View File

@ -220,31 +220,124 @@ describe('should work as expected for the different field types', () => {
{
and: [
{
not: {
address: {
addressStreet1: {
ilike: '%123 Main St%',
or: [
{
not: {
address: {
addressStreet1: {
ilike: '%123 Main St%',
},
},
},
},
},
{
address: {
addressStreet1: {
is: 'NULL',
},
},
},
],
},
{
not: {
address: {
addressStreet2: {
ilike: '%123 Main St%',
or: [
{
not: {
address: {
addressStreet2: {
ilike: '%123 Main St%',
},
},
},
},
},
{
address: {
addressStreet2: {
is: 'NULL',
},
},
},
],
},
{
not: {
address: {
addressCity: {
ilike: '%123 Main St%',
or: [
{
not: {
address: {
addressCity: {
ilike: '%123 Main St%',
},
},
},
},
},
{
address: {
addressCity: {
is: 'NULL',
},
},
},
],
},
{
or: [
{
not: {
address: {
addressState: {
ilike: '%123 Main St%',
},
},
},
},
{
address: {
addressState: {
is: 'NULL',
},
},
},
],
},
{
or: [
{
not: {
address: {
addressPostcode: {
ilike: '%123 Main St%',
},
},
},
},
{
address: {
addressPostcode: {
is: 'NULL',
},
},
},
],
},
{
or: [
{
not: {
address: {
addressCountry: {
ilike: '%123 Main St%',
},
},
},
},
{
address: {
addressCountry: {
is: 'NULL',
},
},
},
],
},
],
},

View File

@ -4,6 +4,7 @@ const COMPOSITE_TYPES_FILTERABLE = [
'ACTOR',
'FULL_NAME',
'CURRENCY',
'ADDRESS',
] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];

View File

@ -392,7 +392,8 @@ export const computeFilterRecordGqlOperationFilter = ({
FieldMetadataType.CURRENCY,
'amountMicros',
subFieldName,
)
) ||
!isSubFieldFilter
) {
switch (filter.operand) {
case RecordFilterOperand.GreaterThan:
@ -579,6 +580,22 @@ export const computeFilterRecordGqlOperationFilter = ({
],
};
} else {
if (subFieldName === 'addressCountry') {
const parsedCountryCodes = JSON.parse(filter.value) as string[];
if (filter.value === '[]' || parsedCountryCodes.length === 0) {
return {};
}
return {
[correspondingField.name]: {
[subFieldName]: {
in: parsedCountryCodes,
} as AddressFilter,
},
};
}
return {
[correspondingField.name]: {
[subFieldName]: {
@ -592,43 +609,176 @@ export const computeFilterRecordGqlOperationFilter = ({
return {
and: [
{
not: {
[correspondingField.name]: {
addressStreet1: {
ilike: `%${filter.value}%`,
or: [
{
not: {
[correspondingField.name]: {
addressStreet1: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
} as AddressFilter,
},
},
{
[correspondingField.name]: {
addressStreet1: {
is: 'NULL',
},
},
},
],
},
{
not: {
[correspondingField.name]: {
addressStreet2: {
ilike: `%${filter.value}%`,
or: [
{
not: {
[correspondingField.name]: {
addressStreet2: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
} as AddressFilter,
},
},
{
[correspondingField.name]: {
addressStreet2: {
is: 'NULL',
},
},
},
],
},
{
not: {
[correspondingField.name]: {
addressCity: {
ilike: `%${filter.value}%`,
or: [
{
not: {
[correspondingField.name]: {
addressCity: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
} as AddressFilter,
},
},
{
[correspondingField.name]: {
addressCity: {
is: 'NULL',
},
},
},
],
},
{
or: [
{
not: {
[correspondingField.name]: {
addressState: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
},
{
[correspondingField.name]: {
addressState: {
is: 'NULL',
},
},
},
],
},
{
or: [
{
not: {
[correspondingField.name]: {
addressPostcode: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
},
{
[correspondingField.name]: {
addressPostcode: {
is: 'NULL',
},
},
},
],
},
{
or: [
{
not: {
[correspondingField.name]: {
addressCountry: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
},
{
[correspondingField.name]: {
addressCountry: {
is: 'NULL',
},
},
},
],
},
],
};
} else {
if (subFieldName === 'addressCountry') {
const parsedCountryCodes = JSON.parse(filter.value) as string[];
if (filter.value === '[]' || parsedCountryCodes.length === 0) {
return {};
}
return {
or: [
{
not: {
[correspondingField.name]: {
addressCountry: {
in: JSON.parse(filter.value),
} as AddressFilter,
},
},
},
{
[correspondingField.name]: {
addressCountry: {
is: 'NULL',
} as AddressFilter,
},
},
],
};
}
return {
not: {
[correspondingField.name]: {
[subFieldName]: {
ilike: `%${filter.value}%`,
} as AddressFilter,
or: [
{
not: {
[correspondingField.name]: {
[subFieldName]: {
ilike: `%${filter.value}%`,
} as AddressFilter,
},
},
},
},
{
[correspondingField.name]: {
[subFieldName]: {
is: 'NULL',
} as AddressFilter,
},
},
],
};
}
default:

View File

@ -17,17 +17,17 @@ export const getDefaultSubFieldNameForCompositeFilterableFieldType = (
case 'CURRENCY':
return 'amountMicros';
case 'LINKS':
return 'primaryLinkUrl';
return undefined;
case 'PHONES':
return 'primaryPhoneNumber';
return undefined;
case 'EMAILS':
return 'primaryEmail';
return undefined;
case 'ADDRESS':
return 'addressCity';
return undefined;
case 'ACTOR':
return 'source';
case 'FULL_NAME':
return 'firstName';
return undefined;
default:
assertUnreachable(compositeFieldType);
}

View File

@ -164,18 +164,8 @@ export const getRecordFilterOperands = ({
)
) {
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`,
);
return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.amountMicros;
}
}
case 'NUMBER':

View File

@ -163,7 +163,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
);
useScopedHotkeys(
[Key.ArrowUp, 'k'],
[Key.ArrowUp],
() => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
move('up');
@ -173,7 +173,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
);
useScopedHotkeys(
[Key.ArrowDown, 'j'],
[Key.ArrowDown],
() => {
setHotkeyScopeAndMemorizePreviousScope(TableHotkeyScope.TableFocus);
move('down');

View File

@ -85,7 +85,7 @@ export const MultipleSelectDropdown = ({
selectableItemIdArray={selectableItemIds}
hotkeyScope={hotkeyScope}
>
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuItemsContainer hasMaxHeight width="auto">
{itemsInDropdown?.map((item) => {
return (
<SelectableListItem

View File

@ -1,4 +1,5 @@
import { AvatarType, IconComponent } from 'twenty-ui/display';
export type SelectableItem<T = object> = T & {
id: string;
name: string;

View File

@ -76,7 +76,7 @@ export const AddressInput = ({
const addressStreet2InputRef = useRef<HTMLInputElement>(null);
const addressCityInputRef = useRef<HTMLInputElement>(null);
const addressStateInputRef = useRef<HTMLInputElement>(null);
const addressPostCodeInputRef = useRef<HTMLInputElement>(null);
const addressPostcodeInputRef = useRef<HTMLInputElement>(null);
const inputRefs: {
[key in keyof FieldAddressDraftValue]?: RefObject<HTMLInputElement>;
@ -85,7 +85,7 @@ export const AddressInput = ({
addressStreet2: addressStreet2InputRef,
addressCity: addressCityInputRef,
addressState: addressStateInputRef,
addressPostcode: addressPostCodeInputRef,
addressPostcode: addressPostcodeInputRef,
};
const [focusPosition, setFocusPosition] =

View File

@ -51,6 +51,7 @@ export const ViewBarFilterDropdown = ({
dropdownHotkeyScope={hotkeyScope}
dropdownOffset={{ y: 8 }}
onClickOutside={handleDropdownClickOutside}
dropdownWidth={208}
/>
);
};

View File

@ -1,27 +1,16 @@
import { ObjectFilterDropdownSubFieldSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSubFieldSelect';
import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { ViewBarFilterDropdownAdvancedFilterButton } from '@/views/components/ViewBarFilterDropdownAdvancedFilterButton';
import { ViewBarFilterDropdownFieldSelectMenu } from '@/views/components/ViewBarFilterDropdownFieldSelectMenu';
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
import { ObjectFilterDropdownFieldSelect } from '../../object-record/object-filter-dropdown/components/ObjectFilterDropdownFieldSelect';
export const ViewBarFilterDropdownContent = () => {
const [objectFilterDropdownIsSelectingCompositeField] =
useRecoilComponentStateV2(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
VIEW_BAR_FILTER_DROPDOWN_ID,
);
const [objectFilterDropdownFilterIsSelected] = useRecoilComponentStateV2(
objectFilterDropdownFilterIsSelectedComponentState,
VIEW_BAR_FILTER_DROPDOWN_ID,
);
const shouldShowCompositeSelectionSubMenu =
objectFilterDropdownIsSelectingCompositeField;
const shouldShowFilterInput = objectFilterDropdownFilterIsSelected;
return (
@ -30,11 +19,9 @@ export const ViewBarFilterDropdownContent = () => {
<ObjectFilterOperandSelectAndInput
filterDropdownId={VIEW_BAR_FILTER_DROPDOWN_ID}
/>
) : shouldShowCompositeSelectionSubMenu ? (
<ObjectFilterDropdownSubFieldSelect />
) : (
<>
<ObjectFilterDropdownFieldSelect />
<ViewBarFilterDropdownFieldSelectMenu />
<ViewBarFilterDropdownAdvancedFilterButton />
</>
)}

View File

@ -2,20 +2,16 @@ import styled from '@emotion/styled';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { FILTER_FIELD_LIST_ID } from '@/object-record/object-filter-dropdown/constants/FilterFieldListId';
import { useFilterDropdownSelectableFieldMetadataItems } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdownSelectableFieldMetadataItems';
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { ViewBarFilterDropdownFieldSelectMenuItem } from '@/views/components/ViewBarFilterDropdownFieldSelectMenuItem';
import { useLingui } from '@lingui/react/macro';
export const StyledInput = styled.input`
@ -44,59 +40,30 @@ export const StyledInput = styled.input`
}
`;
export const ObjectFilterDropdownFieldSelect = () => {
const { recordIndexId } = useRecordIndexContextOrThrow();
export const ViewBarFilterDropdownFieldSelectMenu = () => {
const [objectFilterDropdownSearchInput, setObjectFilterDropdownSearchInput] =
useRecoilComponentStateV2(objectFilterDropdownSearchInputComponentState);
const { filterableFieldMetadataItems } =
useFilterableFieldMetadataItemsInRecordIndexContext();
const visibleTableColumns = useRecoilComponentValueV2(
visibleTableColumnsComponentSelector,
recordIndexId,
);
const visibleColumnsIds = visibleTableColumns.map(
(column) => column.fieldMetadataId,
);
const filteredSearchInputFieldMetadataItems =
filterableFieldMetadataItems.filter((fieldMetadataItem) =>
fieldMetadataItem.label
.toLocaleLowerCase()
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase()),
);
const visibleColumnsFieldMetadataItems = filteredSearchInputFieldMetadataItems
.sort((a, b) => {
return visibleColumnsIds.indexOf(a.id) - visibleColumnsIds.indexOf(b.id);
})
.filter((fieldMetadataItem) =>
visibleColumnsIds.includes(fieldMetadataItem.id),
);
const hiddenColumnsFieldMetadataItems = filteredSearchInputFieldMetadataItems
.sort((a, b) => a.label.localeCompare(b.label))
.filter(
(fieldMetadataItem) => !visibleColumnsIds.includes(fieldMetadataItem.id),
);
const shouldShowSeparator =
visibleColumnsFieldMetadataItems.length > 0 &&
hiddenColumnsFieldMetadataItems.length > 0;
const { t } = useLingui();
const {
selectableHiddenFieldMetadataItems,
selectableVisibleFieldMetadataItems,
} = useFilterDropdownSelectableFieldMetadataItems();
const selectableFieldMetadataItemIds = [
...visibleColumnsFieldMetadataItems.map(
...selectableVisibleFieldMetadataItems.map(
(fieldMetadataItem) => fieldMetadataItem.id,
),
...hiddenColumnsFieldMetadataItems.map(
...selectableHiddenFieldMetadataItems.map(
(fieldMetadataItem) => fieldMetadataItem.id,
),
];
const shouldShowSeparator =
selectableVisibleFieldMetadataItems.length > 0 &&
selectableHiddenFieldMetadataItems.length > 0;
const { t } = useLingui();
return (
<>
<StyledInput
@ -113,15 +80,17 @@ export const ObjectFilterDropdownFieldSelect = () => {
selectableListInstanceId={FILTER_FIELD_LIST_ID}
>
<DropdownMenuItemsContainer>
{visibleColumnsFieldMetadataItems.map((visibleFieldMetadataItem) => (
<ObjectFilterDropdownFilterSelectMenuItem
key={visibleFieldMetadataItem.id}
fieldMetadataItemToSelect={visibleFieldMetadataItem}
/>
))}
{selectableVisibleFieldMetadataItems.map(
(visibleFieldMetadataItem) => (
<ViewBarFilterDropdownFieldSelectMenuItem
key={visibleFieldMetadataItem.id}
fieldMetadataItemToSelect={visibleFieldMetadataItem}
/>
),
)}
{shouldShowSeparator && <DropdownMenuSeparator />}
{hiddenColumnsFieldMetadataItems.map((hiddenFieldMetadataItem) => (
<ObjectFilterDropdownFilterSelectMenuItem
{selectableHiddenFieldMetadataItems.map((hiddenFieldMetadataItem) => (
<ViewBarFilterDropdownFieldSelectMenuItem
key={hiddenFieldMetadataItem.id}
fieldMetadataItemToSelect={hiddenFieldMetadataItem}
/>

View File

@ -0,0 +1,124 @@
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { FILTER_FIELD_LIST_ID } from '@/object-record/object-filter-dropdown/constants/FilterFieldListId';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { SingleRecordPickerHotkeyScope } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerHotkeyScope';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export type ViewBarFilterDropdownFieldSelectMenuItemProps = {
fieldMetadataItemToSelect: FieldMetadataItem;
};
export const ViewBarFilterDropdownFieldSelectMenuItem = ({
fieldMetadataItemToSelect,
}: ViewBarFilterDropdownFieldSelectMenuItemProps) => {
const setFieldMetadataItemIdUsedInDropdown = useSetRecoilComponentStateV2(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const [, setObjectFilterDropdownFilterIsSelected] = useRecoilComponentStateV2(
objectFilterDropdownFilterIsSelectedComponentState,
);
const { resetSelectedItem } = useSelectableList(FILTER_FIELD_LIST_ID);
const isSelectedItem = useRecoilComponentFamilyValueV2(
isSelectedItemIdComponentFamilySelector,
fieldMetadataItemToSelect.id,
);
const setSelectedOperandInDropdown = useSetRecoilComponentStateV2(
selectedOperandInDropdownComponentState,
);
const setHotkeyScope = useSetHotkeyScope();
const currentRecordFilters = useRecoilComponentValueV2(
currentRecordFiltersComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter =
useSetRecoilComponentStateV2(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const handleSelectFilter = (fieldMetadataItem: FieldMetadataItem) => {
setFieldMetadataItemIdUsedInDropdown(fieldMetadataItem.id);
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
if (filterType === 'RELATION' || filterType === 'SELECT') {
setHotkeyScope(SingleRecordPickerHotkeyScope.SingleRecordPicker);
}
const defaultOperand = getRecordFilterOperands({
filterType,
})[0];
setObjectFilterDropdownFilterIsSelected(true);
const duplicateFilterInCurrentRecordFilters =
findDuplicateRecordFilterInNonAdvancedRecordFilters({
recordFilters: currentRecordFilters,
fieldMetadataItemId: fieldMetadataItem.id,
});
const filterIsAlreadyInCurrentRecordFilters = isDefined(
duplicateFilterInCurrentRecordFilters,
);
if (filterIsAlreadyInCurrentRecordFilters) {
setObjectFilterDropdownCurrentRecordFilter(
duplicateFilterInCurrentRecordFilters,
);
setSelectedOperandInDropdown(
duplicateFilterInCurrentRecordFilters.operand,
);
} else {
setSelectedOperandInDropdown(defaultOperand);
}
};
const { getIcon } = useIcons();
const Icon = getIcon(fieldMetadataItemToSelect.icon);
const handleClick = () => {
resetSelectedItem();
handleSelectFilter(fieldMetadataItemToSelect);
};
return (
<SelectableListItem
itemId={fieldMetadataItemToSelect.id}
onEnter={handleClick}
>
<MenuItem
focused={isSelectedItem}
onClick={handleClick}
LeftIcon={Icon}
text={fieldMetadataItemToSelect.label}
/>
</SelectableListItem>
);
};

View File

@ -1,3 +1,4 @@
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand } from './ViewFilterOperand';
export type ViewFilter = {
@ -13,5 +14,5 @@ export type ViewFilter = {
viewId?: string;
viewFilterGroupId?: string;
positionInViewFilterGroup?: number | null;
subFieldName?: string | null;
subFieldName?: CompositeFieldSubFieldName | null;
};

View File

@ -115,6 +115,16 @@ export class GraphqlQueryFilterFieldParser {
subFieldFilter as Record<string, any>,
);
if (
ARRAY_OPERATORS.includes(operator) &&
(!Array.isArray(value) || value.length === 0)
) {
throw new GraphqlQueryRunnerException(
`Invalid filter value for field ${subFieldKey}. Expected non-empty array`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const { sql, params } = computeWhereConditionParts(
operator,
objectNameSingular,

View File

@ -11,7 +11,7 @@ export const PETS_DATA_SEEDS = [
addressStreet2: '7344 Haley Loop',
addressCity: 'Jacksonstad',
addressCountry: 'United States',
addressPostCode: '32048-5208',
addressPostcode: '32048-5208',
addressState: 'North Dakota',
},
vetPhone: {

View File

@ -1,4 +1,3 @@
// eslint-disable-next-line no-restricted-imports
import { FunctionComponent } from 'react';
export type IconComponentProps = {