TWNTY-6808 - Ability to Filter by Creation Source (#7078)

### Description

- Ability to Filter by Creation Source

### Demo

LOOM:
<https://www.loom.com/share/dba9c3d37a4242fe90f977b1babffbde?sid=59b07c51-d245-43cc-bb38-7d898ef72878>

### Refs

#6808

Fixes #6808

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
gitstart-app[bot]
2024-10-02 17:56:09 +02:00
committed by GitHub
parent 2cd3219636
commit 35788af351
27 changed files with 686 additions and 155 deletions

View File

@ -92,6 +92,7 @@ export type LinksFilter = {
export type ActorFilter = {
name?: StringFilter;
source?: IsFilter;
};
export type EmailsFilter = {

View File

@ -2,6 +2,11 @@ import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-d
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
import { ObjectFilterDropdownSourceSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect';
import { ObjectFilterDropdownTextSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { MultipleFiltersDropdownFilterOnFilterChangedEffect } from './MultipleFiltersDropdownFilterOnFilterChangedEffect';
@ -11,8 +16,6 @@ import { ObjectFilterDropdownNumberInput } from './ObjectFilterDropdownNumberInp
import { ObjectFilterDropdownOperandButton } from './ObjectFilterDropdownOperandButton';
import { ObjectFilterDropdownOperandSelect } from './ObjectFilterDropdownOperandSelect';
import { ObjectFilterDropdownOptionSelect } from './ObjectFilterDropdownOptionSelect';
import { ObjectFilterDropdownRecordSelect } from './ObjectFilterDropdownRecordSelect';
import { ObjectFilterDropdownTextSearchInput } from './ObjectFilterDropdownTextSearchInput';
const StyledContainer = styled.div`
position: relative;
@ -113,6 +116,12 @@ export const MultipleFiltersDropdownContent = ({
<ObjectFilterDropdownRecordSelect />
</>
)}
{filterDefinitionUsedInDropdown.type === 'SOURCE' && (
<>
<DropdownMenuSeparator />
<ObjectFilterDropdownSourceSelect />
</>
)}
{filterDefinitionUsedInDropdown.type === 'SELECT' && (
<>
<ObjectFilterDropdownSearchInput />

View File

@ -1,17 +1,15 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { ObjectFilterSelectMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterSelectMenu';
import { ObjectFilterSelectSubMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterSelectSubMenu';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { useSelectFilter } from '@/object-record/object-filter-dropdown/hooks/useSelectFilter';
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { currentSubMenuState } from '@/object-record/object-filter-dropdown/states/subMenuStates';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const StyledInput = styled.input`
@ -47,6 +45,9 @@ export const ObjectFilterDropdownFilterSelect = () => {
availableFilterDefinitionsComponentState,
);
const [currentSubMenu, setCurrentSubMenu] =
useRecoilState(currentSubMenuState);
const sortedAvailableFilterDefinitions = [...availableFilterDefinitions]
.sort((a, b) => a.label.localeCompare(b.label))
.filter((item) =>
@ -75,37 +76,21 @@ export const ObjectFilterDropdownFilterSelect = () => {
selectFilter({ filterDefinition: selectedFilterDefinition });
};
return (
<>
<StyledInput
value={searchText}
autoFocus
placeholder="Search fields"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearchText(event.target.value)
}
/>
<SelectableList
hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton}
selectableItemIdArray={selectableListItemIds}
selectableListId={OBJECT_FILTER_DROPDOWN_ID}
onEnter={handleEnter}
>
<DropdownMenuItemsContainer>
{sortedAvailableFilterDefinitions.map(
(availableFilterDefinition, index) => (
<SelectableItem
itemId={availableFilterDefinition.fieldMetadataId}
key={`select-filter-${index}`}
>
<ObjectFilterDropdownFilterSelectMenuItem
filterDefinition={availableFilterDefinition}
/>
</SelectableItem>
),
)}
</DropdownMenuItemsContainer>
</SelectableList>
</>
useEffect(() => {
return () => {
setCurrentSubMenu(null);
};
}, [setCurrentSubMenu]);
return !currentSubMenu ? (
<ObjectFilterSelectMenu
searchText={searchText}
setSearchText={setSearchText}
sortedAvailableFilterDefinitions={sortedAvailableFilterDefinitions}
selectableListItemIds={selectableListItemIds}
handleEnter={handleEnter}
/>
) : (
<ObjectFilterSelectSubMenu />
);
};

View File

@ -1,9 +1,14 @@
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { useSelectFilter } from '@/object-record/object-filter-dropdown/hooks/useSelectFilter';
import {
currentParentFilterDefinitionState,
currentSubMenuState,
} from '@/object-record/object-filter-dropdown/states/subMenuStates';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { hasSubMenuFilter } from '@/object-record/object-filter-dropdown/utils/hasSubMenuFilter';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useIcons } from 'twenty-ui';
export type ObjectFilterDropdownFilterSelectMenuItemProps = {
@ -23,12 +28,24 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
isSelectedItemIdSelector(filterDefinition.fieldMetadataId),
);
const hasSubMenu = hasSubMenuFilter(filterDefinition.type);
const { getIcon } = useIcons();
const setCurrentSubMenu = useSetRecoilState(currentSubMenuState);
const setCurrentParentFilterDefinition = useSetRecoilState(
currentParentFilterDefinitionState,
);
const handleClick = () => {
resetSelectedItem();
selectFilter({ filterDefinition });
if (hasSubMenu) {
setCurrentSubMenu(filterDefinition.type);
setCurrentParentFilterDefinition(filterDefinition);
} else {
selectFilter({ filterDefinition });
}
};
return (
@ -38,6 +55,7 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
onClick={handleClick}
LeftIcon={getIcon(filterDefinition.iconName)}
text={filterDefinition.label}
hasSubMenu={hasSubMenu}
/>
);
};

View File

@ -4,9 +4,9 @@ import { v4 } from 'uuid';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { MultipleRecordSelectDropdown } from '@/object-record/select/components/MultipleRecordSelectDropdown';
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { isDefined } from '~/utils/isDefined';
@ -66,7 +66,7 @@ export const ObjectFilterDropdownRecordSelect = ({
});
const handleMultipleRecordSelectChange = (
recordToSelect: SelectableRecord,
recordToSelect: SelectableItem,
newSelectedValue: boolean,
) => {
if (loading) {
@ -134,15 +134,15 @@ export const ObjectFilterDropdownRecordSelect = ({
};
return (
<MultipleRecordSelectDropdown
<MultipleSelectDropdown
selectableListId="object-filter-record-select-id"
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
recordsToSelect={recordsToSelect}
filteredSelectedRecords={filteredSelectedRecords}
selectedRecords={selectedRecords}
itemsToSelect={recordsToSelect}
filteredSelectedItems={filteredSelectedRecords}
selectedItems={selectedRecords}
onChange={handleMultipleRecordSelectChange}
searchFilter={objectFilterDropdownSearchInput}
loadingRecords={loading}
loadingItems={loading}
/>
);
};

View File

@ -0,0 +1,137 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { getSourceEnumOptions } from '@/object-record/object-filter-dropdown/utils/getSourceEnumOptions';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { isDefined } from '~/utils/isDefined';
export const EMPTY_FILTER_VALUE = '[]';
export const MAX_ITEMS_TO_DISPLAY = 3;
type ObjectFilterDropdownSourceSelectProps = {
viewComponentId?: string;
};
export const ObjectFilterDropdownSourceSelect = ({
viewComponentId,
}: ObjectFilterDropdownSourceSelectProps) => {
const {
filterDefinitionUsedInDropdownState,
objectFilterDropdownSearchInputState,
selectedOperandInDropdownState,
selectedFilterState,
setObjectFilterDropdownSelectedRecordIds,
objectFilterDropdownSelectedRecordIdsState,
selectFilter,
emptyFilterButKeepDefinition,
} = useFilterDropdown();
const { deleteCombinedViewFilter } =
useDeleteCombinedViewFilters(viewComponentId);
const { currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView(viewComponentId);
const filterDefinitionUsedInDropdown = useRecoilValue(
filterDefinitionUsedInDropdownState,
);
const objectFilterDropdownSearchInput = useRecoilValue(
objectFilterDropdownSearchInputState,
);
const selectedOperandInDropdown = useRecoilValue(
selectedOperandInDropdownState,
);
const objectFilterDropdownSelectedRecordIds = useRecoilValue(
objectFilterDropdownSelectedRecordIdsState,
);
const [fieldId] = useState(v4());
const selectedFilter = useRecoilValue(selectedFilterState);
const sourceTypes = getSourceEnumOptions(
objectFilterDropdownSelectedRecordIds,
);
const filteredSelectedItems = sourceTypes.filter((option) =>
objectFilterDropdownSelectedRecordIds.includes(option.id),
);
const handleMultipleItemSelectChange = (
itemToSelect: SelectableItem,
newSelectedValue: boolean,
) => {
const newSelectedItemIds = newSelectedValue
? [...objectFilterDropdownSelectedRecordIds, itemToSelect.id]
: objectFilterDropdownSelectedRecordIds.filter(
(id) => id !== itemToSelect.id,
);
if (newSelectedItemIds.length === 0) {
emptyFilterButKeepDefinition();
deleteCombinedViewFilter(fieldId);
return;
}
setObjectFilterDropdownSelectedRecordIds(newSelectedItemIds);
const selectedItemNames = sourceTypes
.filter((option) => newSelectedItemIds.includes(option.id))
.map((option) => option.name);
const filterDisplayValue =
selectedItemNames.length > MAX_ITEMS_TO_DISPLAY
? `${selectedItemNames.length} source types`
: selectedItemNames.join(', ');
if (
isDefined(filterDefinitionUsedInDropdown) &&
isDefined(selectedOperandInDropdown)
) {
const newFilterValue =
newSelectedItemIds.length > 0
? JSON.stringify(newSelectedItemIds)
: EMPTY_FILTER_VALUE;
const viewFilter =
currentViewWithCombinedFiltersAndSorts?.viewFilters.find(
(viewFilter) =>
viewFilter.fieldMetadataId ===
filterDefinitionUsedInDropdown.fieldMetadataId,
);
const filterId = viewFilter?.id ?? fieldId;
selectFilter({
id: selectedFilter?.id ? selectedFilter.id : filterId,
definition: filterDefinitionUsedInDropdown,
operand: selectedOperandInDropdown || ViewFilterOperand.Is,
displayValue: filterDisplayValue,
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: newFilterValue,
});
}
};
return (
<MultipleSelectDropdown
selectableListId="object-filter-source-select-id"
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
itemsToSelect={sourceTypes.filter(
(item) =>
!filteredSelectedItems.some((selected) => selected.id === item.id),
)}
filteredSelectedItems={filteredSelectedItems}
selectedItems={filteredSelectedItems}
onChange={handleMultipleItemSelectChange}
searchFilter={objectFilterDropdownSearchInput}
loadingItems={false}
/>
);
};

View File

@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
export const StyledInput = styled.input`
background: transparent;
border: none;
border-top: none;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 0;
border-top-left-radius: ${({ theme }) => theme.border.radius.md};
border-top-right-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.primary};
margin: 0;
outline: none;
padding: ${({ theme }) => theme.spacing(2)};
height: 19px;
font-family: inherit;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: inherit;
max-width: 100%;
overflow: hidden;
text-decoration: none;
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
}
`;
type ObjectFilterSelectMenuProps = {
searchText: string;
setSearchText: (searchText: string) => void;
sortedAvailableFilterDefinitions: FilterDefinition[];
selectableListItemIds: string[];
handleEnter: (itemId: string) => void;
};
export const ObjectFilterSelectMenu = ({
searchText,
setSearchText,
sortedAvailableFilterDefinitions,
selectableListItemIds,
handleEnter,
}: ObjectFilterSelectMenuProps) => {
return (
<>
<StyledInput
value={searchText}
autoFocus
placeholder="Search fields"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearchText(event.target.value)
}
/>
<SelectableList
hotkeyScope={FiltersHotkeyScope.ObjectFilterDropdownButton}
selectableItemIdArray={selectableListItemIds}
selectableListId={OBJECT_FILTER_DROPDOWN_ID}
onEnter={handleEnter}
>
<DropdownMenuItemsContainer>
{sortedAvailableFilterDefinitions.map(
(availableFilterDefinition: FilterDefinition, index: number) => (
<SelectableItem
key={`selectable-item-${availableFilterDefinition.fieldMetadataId}`}
itemId={availableFilterDefinition.fieldMetadataId}
>
<ObjectFilterDropdownFilterSelectMenuItem
key={`select-filter-${index}`}
filterDefinition={availableFilterDefinition}
/>
</SelectableItem>
),
)}
</DropdownMenuItemsContainer>
</SelectableList>
</>
);
};

View File

@ -0,0 +1,102 @@
import { StyledInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import {
currentParentFilterDefinitionState,
currentSubMenuState,
} from '@/object-record/object-filter-dropdown/states/subMenuStates';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
import { getHeaderTitle } from '@/object-record/object-filter-dropdown/utils/getHeaderTitle';
import { getOperandsForFilterType } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { getSubMenuOptions } from '@/object-record/object-filter-dropdown/utils/getSubMenuOptions';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconChevronLeft, useIcons } from 'twenty-ui';
export const ObjectFilterSelectSubMenu = () => {
const [searchText, setSearchText] = useState('');
const { getIcon } = useIcons();
const [currentSubMenu, setCurrentSubMenu] =
useRecoilState(currentSubMenuState);
const currentParentFilterDefinition = useRecoilValue(
currentParentFilterDefinitionState,
);
const {
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
setObjectFilterDropdownSearchInput,
} = useFilterDropdown();
const setHotkeyScope = useSetHotkeyScope();
const handleSelectFilter = (definition: FilterDefinition | null) => {
if (definition !== null) {
setFilterDefinitionUsedInDropdown(definition);
if (definition.type === 'SOURCE') {
setHotkeyScope(RelationPickerHotkeyScope.RelationPicker);
}
setSelectedOperandInDropdown(
getOperandsForFilterType(definition.type)?.[0],
);
setObjectFilterDropdownSearchInput('');
}
};
return (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => {
setCurrentSubMenu(null);
}}
>
{getHeaderTitle(currentSubMenu)}
</DropdownMenuHeader>
<StyledInput
value={searchText}
autoFocus
placeholder="Search fields"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearchText(event.target.value)
}
/>
<DropdownMenuItemsContainer>
{getSubMenuOptions(currentSubMenu)
.sort((a, b) => a.name.localeCompare(b.name))
.filter((item) =>
item.name
.toLocaleLowerCase()
.includes(searchText.toLocaleLowerCase()),
)
.map((menuOption, index) => (
<MenuItem
key={`select-filter-${index}`}
testId={`select-filter-${index}`}
onClick={() => {
currentParentFilterDefinition &&
handleSelectFilter({
...currentParentFilterDefinition,
label: menuOption.name,
type: menuOption.type as FilterType,
});
}}
text={menuOption.name}
LeftIcon={getIcon(
menuOption.icon || currentParentFilterDefinition?.iconName,
)}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,15 @@
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
import { atom } from 'recoil';
export const currentSubMenuState = atom<FilterType | null>({
key: 'currentSubMenuState',
default: null,
});
export const currentParentFilterDefinitionState = atom<FilterDefinition | null>(
{
key: 'currentParentFilterDefinitionState',
default: null,
},
);

View File

@ -11,4 +11,5 @@ export type FilterDefinition = {
relationObjectMetadataNameSingular?: string;
selectAllLabel?: string;
SelectAllIcon?: IconComponent;
subFieldType?: FilterType;
};

View File

@ -17,4 +17,5 @@ export type FilterType =
| 'RATING'
| 'MULTI_SELECT'
| 'ACTOR'
| 'ARRAY';
| 'ARRAY'
| 'SOURCE';

View File

@ -0,0 +1,14 @@
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
export const getHeaderTitle = (
subMenu: FilterType | null,
): string | undefined => {
switch (subMenu) {
case 'ACTOR':
return 'Actor';
case 'SOURCE':
return 'Creation Source';
default:
return undefined;
}
};

View File

@ -57,6 +57,8 @@ export const getOperandsForFilterType = (
];
case 'RELATION':
return [...relationOperands, ...emptyOperands];
case 'SOURCE':
return [...relationOperands];
case 'SELECT':
return [...relationOperands];
default:

View File

@ -0,0 +1,56 @@
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import {
IconApi,
IconCsv,
IconGmail,
IconGoogleCalendar,
IconSettingsAutomation,
IconUserCircle,
} from 'twenty-ui';
export const getSourceEnumOptions = (
selectedItemIds: string[],
): SelectableItem[] => {
return [
{
id: 'MANUAL',
name: 'User',
isSelected: selectedItemIds.includes('MANUAL'),
AvatarIcon: IconUserCircle,
isIconInverted: true,
},
{
id: 'IMPORT',
name: 'Import',
isSelected: selectedItemIds.includes('IMPORT'),
AvatarIcon: IconCsv,
isIconInverted: true,
},
{
id: 'API',
name: 'Api',
isSelected: selectedItemIds.includes('API'),
AvatarIcon: IconApi,
isIconInverted: true,
},
{
id: 'EMAIL',
name: 'Email',
isSelected: selectedItemIds.includes('EMAIL'),
AvatarIcon: IconGmail,
},
{
id: 'CALENDAR',
name: 'Calendar',
isSelected: selectedItemIds.includes('CALENDAR'),
AvatarIcon: IconGoogleCalendar,
},
{
id: 'WORKFLOW',
name: 'Workflow',
isSelected: selectedItemIds.includes('WORKFLOW'),
AvatarIcon: IconSettingsAutomation,
isIconInverted: true,
},
];
};

View File

@ -0,0 +1,21 @@
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
export const getSubMenuOptions = (subMenu: FilterType | null) => {
switch (subMenu) {
case 'ACTOR':
return [
{
name: 'Creation Source',
icon: 'IconPlug',
type: 'SOURCE',
},
{
name: 'Creator Name',
icon: 'IconId',
type: 'ACTOR',
},
];
default:
return [];
}
};

View File

@ -0,0 +1,3 @@
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
export const hasSubMenuFilter = (type: FilterType) => ['ACTOR'].includes(type);

View File

@ -30,12 +30,6 @@ import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns';
import { z } from 'zod';
import { Filter } from '../../object-filter-dropdown/types/Filter';
export type ObjectDropdownFilter = Omit<Filter, 'definition'> & {
definition: {
type: Filter['definition']['type'];
};
};
const applyEmptyFilters = (
operand: ViewFilterOperand,
correspondingField: Pick<Field, 'id' | 'name'>,
@ -282,7 +276,7 @@ const applyEmptyFilters = (
};
export const turnObjectDropdownFilterIntoQueryFilter = (
rawUIFilters: ObjectDropdownFilter[],
rawUIFilters: Filter[],
fields: Pick<Field, 'id' | 'name'>[],
): RecordGqlOperationFilter | undefined => {
const objectRecordFilters: RecordGqlOperationFilter[] = [];
@ -894,48 +888,87 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
break;
}
case 'ACTOR':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
name: {
ilike: `%${rawUIFilter.value}%`,
if (rawUIFilter.definition.subFieldType !== undefined) {
const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[];
switch (rawUIFilter.definition.subFieldType) {
case 'SOURCE':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
[correspondingField.name]: {
source: {
in: parsedRecordIds,
} as RelationFilter,
},
} as ActorFilter,
},
],
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: [
{
not: {
});
break;
case ViewFilterOperand.IsNot:
if (parsedRecordIds.length > 0) {
objectRecordFilters.push({
not: {
[correspondingField.name]: {
[rawUIFilter.definition.subFieldType.toLowerCase()]: {
in: parsedRecordIds,
} as RelationFilter,
},
},
});
}
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.subFieldType} filter`,
);
}
}
} else {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
name: {
ilike: `%${rawUIFilter.value}%`,
},
} as ActorFilter,
},
},
],
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
],
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: [
{
not: {
[correspondingField.name]: {
name: {
ilike: `%${rawUIFilter.value}%`,
},
} as ActorFilter,
},
},
],
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
}
break;
case 'EMAILS':

View File

@ -1,9 +1,10 @@
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { Avatar } from 'twenty-ui';
import { AvatarChip } from 'twenty-ui';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
@ -14,26 +15,36 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
export const MultipleRecordSelectDropdown = ({
const StyledAvatarChip = styled(AvatarChip)`
&.avatar-icon-container {
color: ${({ theme }) => theme.font.color.secondary};
gap: ${({ theme }) => theme.spacing(2)};
padding-left: 0px;
padding-right: 0px;
font-size: ${({ theme }) => theme.font.size.md};
}
`;
export const MultipleSelectDropdown = ({
selectableListId,
hotkeyScope,
recordsToSelect,
loadingRecords,
filteredSelectedRecords,
itemsToSelect,
loadingItems,
filteredSelectedItems,
onChange,
searchFilter,
}: {
selectableListId: string;
hotkeyScope: string;
recordsToSelect: SelectableRecord[];
filteredSelectedRecords: SelectableRecord[];
selectedRecords: SelectableRecord[];
itemsToSelect: SelectableItem[];
filteredSelectedItems: SelectableItem[];
selectedItems: SelectableItem[];
searchFilter: string;
onChange: (
changedRecordToSelect: SelectableRecord,
changedItemToSelect: SelectableItem,
newSelectedValue: boolean,
) => void;
loadingRecords: boolean;
loadingItems: boolean;
}) => {
const { closeDropdown } = useDropdown();
const { selectedItemIdState } = useSelectableListStates({
@ -44,32 +55,32 @@ export const MultipleRecordSelectDropdown = ({
const selectedItemId = useRecoilValue(selectedItemIdState);
const handleRecordSelectChange = (
recordToSelect: SelectableRecord,
const handleItemSelectChange = (
itemToSelect: SelectableItem,
newSelectedValue: boolean,
) => {
onChange(
{
...recordToSelect,
...itemToSelect,
isSelected: newSelectedValue,
},
newSelectedValue,
);
};
const [recordsInDropdown, setRecordInDropdown] = useState([
...(filteredSelectedRecords ?? []),
...(recordsToSelect ?? []),
const [itemsInDropdown, setItemInDropdown] = useState([
...(filteredSelectedItems ?? []),
...(itemsToSelect ?? []),
]);
useEffect(() => {
if (!loadingRecords) {
setRecordInDropdown([
...(filteredSelectedRecords ?? []),
...(recordsToSelect ?? []),
if (!loadingItems) {
setItemInDropdown([
...(filteredSelectedItems ?? []),
...(itemsToSelect ?? []),
]);
}
}, [recordsToSelect, filteredSelectedRecords, loadingRecords]);
}, [itemsToSelect, filteredSelectedItems, loadingItems]);
useScopedHotkeys(
[Key.Escape],
@ -82,12 +93,12 @@ export const MultipleRecordSelectDropdown = ({
);
const showNoResult =
recordsToSelect?.length === 0 &&
itemsToSelect?.length === 0 &&
searchFilter !== '' &&
filteredSelectedRecords?.length === 0 &&
!loadingRecords;
filteredSelectedItems?.length === 0 &&
!loadingItems;
const selectableItemIds = recordsInDropdown.map((record) => record.id);
const selectableItemIds = itemsInDropdown.map((item) => item.id);
return (
<SelectableList
@ -95,45 +106,46 @@ export const MultipleRecordSelectDropdown = ({
selectableItemIdArray={selectableItemIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const record = recordsInDropdown.findIndex(
const item = itemsInDropdown.findIndex(
(entity) => entity.id === itemId,
);
const recordIsSelectedInDropwdown = filteredSelectedRecords.find(
const itemIsSelectedInDropwdown = filteredSelectedItems.find(
(entity) => entity.id === itemId,
);
handleRecordSelectChange(
recordsInDropdown[record],
!recordIsSelectedInDropwdown,
handleItemSelectChange(
itemsInDropdown[item],
!itemIsSelectedInDropwdown,
);
resetSelectedItem();
}}
>
<DropdownMenuItemsContainer hasMaxHeight>
{recordsInDropdown?.map((record) => {
{itemsInDropdown?.map((item) => {
return (
<MenuItemMultiSelectAvatar
key={record.id}
selected={record.isSelected}
isKeySelected={record.id === selectedItemId}
key={item.id}
selected={item.isSelected}
isKeySelected={item.id === selectedItemId}
onSelectChange={(newCheckedValue) => {
resetSelectedItem();
handleRecordSelectChange(record, newCheckedValue);
handleItemSelectChange(item, newCheckedValue);
}}
avatar={
<Avatar
avatarUrl={record.avatarUrl}
placeholderColorSeed={record.id}
placeholder={record.name}
size="md"
type={record.avatarType ?? 'rounded'}
<StyledAvatarChip
className="avatar-icon-container"
name={item.name}
avatarUrl={item.avatarUrl}
LeftIcon={item.AvatarIcon}
avatarType={item.avatarType}
isIconInverted={item.isIconInverted}
placeholderColorSeed={item.id}
/>
}
text={record.name}
/>
);
})}
{showNoResult && <MenuItem text="No result" />}
{loadingRecords && <DropdownMenuSkeletonItem />}
{loadingItems && <DropdownMenuSkeletonItem />}
</DropdownMenuItemsContainer>
</SelectableList>
);

View File

@ -5,7 +5,7 @@ import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapTo
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { SelectableItem } from '@/object-record/select/types/SelectableItem';
import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
@ -109,19 +109,19 @@ export const useRecordsForSelect = ({
.map((record) => ({
...record,
isSelected: true,
})) as SelectableRecord[],
})) as SelectableItem[],
filteredSelectedRecords: filteredSelectedRecordsData
.map(mapToObjectRecordIdentifier)
.map((record) => ({
...record,
isSelected: true,
})) as SelectableRecord[],
})) as SelectableItem[],
recordsToSelect: recordsToSelectData
.map(mapToObjectRecordIdentifier)
.map((record) => ({
...record,
isSelected: false,
})) as SelectableRecord[],
})) as SelectableItem[],
loading:
recordsToSelectLoading ||
filteredSelectedRecordsLoading ||

View File

@ -0,0 +1,11 @@
import { AvatarType, IconComponent } from 'twenty-ui';
export type SelectableItem<T = object> = T & {
id: string;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
AvatarIcon?: IconComponent;
isSelected: boolean;
isIconInverted?: boolean;
};

View File

@ -1,10 +0,0 @@
import { AvatarType } from 'twenty-ui';
export type SelectableRecord = {
id: string;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
record: any;
isSelected: boolean;
};

View File

@ -22,7 +22,7 @@ type MenuItemMultiSelectAvatarProps = {
avatar?: ReactNode;
selected: boolean;
isKeySelected?: boolean;
text: string;
text?: string;
className?: string;
onSelectChange?: (selected: boolean) => void;
};

View File

@ -1,6 +1,6 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck, IconComponent } from 'twenty-ui';
import { IconCheck, IconChevronRight, IconComponent } from 'twenty-ui';
import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
@ -45,6 +45,7 @@ type MenuItemSelectProps = {
onClick?: () => void;
disabled?: boolean;
hovered?: boolean;
hasSubMenu?: boolean;
};
export const MenuItemSelect = ({
@ -55,6 +56,7 @@ export const MenuItemSelect = ({
onClick,
disabled,
hovered,
hasSubMenu = false,
}: MenuItemSelectProps) => {
const theme = useTheme();
@ -68,6 +70,12 @@ export const MenuItemSelect = ({
>
<MenuItemLeftContent LeftIcon={LeftIcon} text={text} />
{selected && <IconCheck size={theme.icon.size.md} />}
{hasSubMenu && (
<IconChevronRight
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
/>
)}
</StyledMenuItemSelect>
);
};

View File

@ -1,3 +1,4 @@
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { ViewFilterOperand } from './ViewFilterOperand';
export type ViewFilter = {
@ -11,4 +12,5 @@ export type ViewFilter = {
createdAt?: string;
updatedAt?: string;
viewId?: string;
definition?: FilterDefinition;
};

View File

@ -0,0 +1,17 @@
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { hasSubMenuFilter } from '@/object-record/object-filter-dropdown/utils/hasSubMenuFilter';
import { ViewFilter } from '../types/ViewFilter';
export const getFilterDefinitionForViewFilter = (
viewFilter: ViewFilter,
availableFilterDefinition: FilterDefinition,
): FilterDefinition => {
return {
...availableFilterDefinition,
subFieldType:
hasSubMenuFilter(availableFilterDefinition.type) &&
viewFilter.definition?.type !== availableFilterDefinition.type
? viewFilter.definition?.type
: undefined,
};
};

View File

@ -2,6 +2,7 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { isDefined } from '~/utils/isDefined';
import { getFilterDefinitionForViewFilter } from '@/views/utils/getFilterDefinitionForViewFilter';
import { ViewFilter } from '../types/ViewFilter';
export const mapViewFiltersToFilters = (
@ -23,7 +24,10 @@ export const mapViewFiltersToFilters = (
value: viewFilter.value,
displayValue: viewFilter.displayValue,
operand: viewFilter.operand,
definition: availableFilterDefinition,
definition: getFilterDefinitionForViewFilter(
viewFilter,
availableFilterDefinition,
),
};
})
.filter(isDefined);

View File

@ -127,6 +127,7 @@ export const Chip = ({
rightComponent,
accent = ChipAccent.TextPrimary,
onClick,
className,
}: ChipProps) => {
return (
<StyledContainer
@ -137,6 +138,7 @@ export const Chip = ({
size={size}
variant={variant}
onClick={onClick}
className={className}
>
{leftComponent}
<OverflowingTextWithTooltip