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 09:32:46 +00:00
committed by GitHub
parent 97d44d13ba
commit afea017c12
37 changed files with 932 additions and 210 deletions
@@ -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>
</>
);
};
@@ -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,
);
@@ -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 />
@@ -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;
@@ -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;
@@ -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;
@@ -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>
@@ -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 = () => {
@@ -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);
}
}
},
@@ -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>
</>
);
};
@@ -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
@@ -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) && (
<>
@@ -5,7 +5,6 @@ export const TEXT_FILTER_TYPES = [
'FULL_NAME',
'LINK',
'LINKS',
'ADDRESS',
'ARRAY',
'RAW_JSON',
];
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
@@ -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 });
};
@@ -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,
});
@@ -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`}
@@ -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> =
@@ -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',
},
},
},
],
},
],
},
@@ -4,6 +4,7 @@ const COMPOSITE_TYPES_FILTERABLE = [
'ACTOR',
'FULL_NAME',
'CURRENCY',
'ADDRESS',
] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];
@@ -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:
@@ -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);
}
@@ -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':
@@ -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');
@@ -85,7 +85,7 @@ export const MultipleSelectDropdown = ({
selectableItemIdArray={selectableItemIds}
hotkeyScope={hotkeyScope}
>
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuItemsContainer hasMaxHeight width="auto">
{itemsInDropdown?.map((item) => {
return (
<SelectableListItem
@@ -1,4 +1,5 @@
import { AvatarType, IconComponent } from 'twenty-ui/display';
export type SelectableItem<T = object> = T & {
id: string;
name: string;
@@ -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] =
@@ -51,6 +51,7 @@ export const ViewBarFilterDropdown = ({
dropdownHotkeyScope={hotkeyScope}
dropdownOffset={{ y: 8 }}
onClickOutside={handleDropdownClickOutside}
dropdownWidth={208}
/>
);
};
@@ -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 />
</>
)}
@@ -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}
/>
@@ -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>
);
};
@@ -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;
};
@@ -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,
@@ -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: {
@@ -1,4 +1,3 @@
// eslint-disable-next-line no-restricted-imports
import { FunctionComponent } from 'react';
export type IconComponentProps = {