Feat: Advanced filter (#7700)

Design:


![twenty-advanced-filters-design](https://github.com/user-attachments/assets/7d99971c-9ee1-4a78-a2fb-7ae5a9b3a836)

Not ready to be merged yet!

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
ad-elias
2024-10-24 16:59:59 +02:00
committed by GitHub
parent 1dfeba39eb
commit 315820ec86
99 changed files with 3349 additions and 1079 deletions

View File

@ -0,0 +1,161 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useUpsertCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup';
import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
import { useCallback } from 'react';
import { IconLibraryPlus, IconPlus, isDefined, LightButton } from 'twenty-ui';
import { v4 } from 'uuid';
type AdvancedFilterAddFilterRuleSelectProps = {
viewFilterGroup: ViewFilterGroup;
lastChildPosition?: number;
};
export const AdvancedFilterAddFilterRuleSelect = ({
viewFilterGroup,
lastChildPosition = 0,
}: AdvancedFilterAddFilterRuleSelectProps) => {
const dropdownId = `advanced-filter-add-filter-rule-${viewFilterGroup.id}`;
const { currentViewId } = useGetCurrentView();
const { upsertCombinedViewFilterGroup } = useUpsertCombinedViewFilterGroup();
const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters();
const newPositionInViewFilterGroup = lastChildPosition + 1;
const { closeDropdown } = useDropdown(dropdownId);
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const objectMetadataId =
currentViewWithCombinedFiltersAndSorts?.objectMetadataId;
if (!objectMetadataId) {
throw new Error('Object metadata id is missing from current view');
}
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const availableFilterDefinitions = useRecoilComponentValueV2(
availableFilterDefinitionsComponentState,
);
const getDefaultFilterDefinition = useCallback(() => {
const defaultFilterDefinition =
availableFilterDefinitions.find(
(filterDefinition) =>
filterDefinition.fieldMetadataId ===
objectMetadataItem?.labelIdentifierFieldMetadataId,
) ?? availableFilterDefinitions?.[0];
if (!defaultFilterDefinition) {
throw new Error('Missing default filter definition');
}
return defaultFilterDefinition;
}, [availableFilterDefinitions, objectMetadataItem]);
const handleAddFilter = () => {
closeDropdown();
const defaultFilterDefinition = getDefaultFilterDefinition();
upsertCombinedViewFilter({
id: v4(),
fieldMetadataId: defaultFilterDefinition.fieldMetadataId,
operand: getOperandsForFilterDefinition(defaultFilterDefinition)[0],
definition: defaultFilterDefinition,
value: '',
displayValue: '',
viewFilterGroupId: viewFilterGroup.id,
positionInViewFilterGroup: newPositionInViewFilterGroup,
});
};
const handleAddFilterGroup = () => {
closeDropdown();
if (!currentViewId) {
throw new Error('Missing view id');
}
const newViewFilterGroup = {
id: v4(),
viewId: currentViewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
parentViewFilterGroupId: viewFilterGroup.id,
positionInViewFilterGroup: newPositionInViewFilterGroup,
};
upsertCombinedViewFilterGroup(newViewFilterGroup);
const defaultFilterDefinition = getDefaultFilterDefinition();
upsertCombinedViewFilter({
id: v4(),
fieldMetadataId: defaultFilterDefinition.fieldMetadataId,
operand: getOperandsForFilterDefinition(defaultFilterDefinition)[0],
definition: defaultFilterDefinition,
value: '',
displayValue: '',
viewFilterGroupId: newViewFilterGroup.id,
positionInViewFilterGroup: newPositionInViewFilterGroup,
});
};
const isFilterRuleGroupOptionVisible = !isDefined(
viewFilterGroup.parentViewFilterGroupId,
);
if (!isFilterRuleGroupOptionVisible) {
return (
<LightButton
Icon={IconPlus}
title="Add filter rule"
onClick={handleAddFilter}
/>
);
}
return (
<Dropdown
disableBlur
dropdownId={dropdownId}
clickableComponent={
<LightButton Icon={IconPlus} title="Add filter rule" />
}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPlus}
text="Add rule"
onClick={handleAddFilter}
/>
{isFilterRuleGroupOptionVisible && (
<MenuItem
LeftIcon={IconLibraryPlus}
text="Add rule group"
onClick={handleAddFilterGroup}
/>
)}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
/>
);
};

View File

@ -0,0 +1,41 @@
import { AdvancedFilterLogicalOperatorDropdown } from '@/object-record/advanced-filter/components/AdvancedFilterLogicalOperatorDropdown';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import styled from '@emotion/styled';
import { capitalize } from '~/utils/string/capitalize';
const StyledText = styled.div`
height: ${({ theme }) => theme.spacing(8)};
display: flex;
align-items: center;
`;
const StyledContainer = styled.div`
align-items: start;
display: flex;
min-width: ${({ theme }) => theme.spacing(20)};
color: ${({ theme }) => theme.font.color.tertiary};
`;
type AdvancedFilterLogicalOperatorCellProps = {
index: number;
viewFilterGroup: ViewFilterGroup;
};
export const AdvancedFilterLogicalOperatorCell = ({
index,
viewFilterGroup,
}: AdvancedFilterLogicalOperatorCellProps) => (
<StyledContainer>
{index === 0 ? (
<StyledText>Where</StyledText>
) : index === 1 ? (
<AdvancedFilterLogicalOperatorDropdown
viewFilterGroup={viewFilterGroup}
/>
) : (
<StyledText>
{capitalize(viewFilterGroup.logicalOperator.toLowerCase())}
</StyledText>
)}
</StyledContainer>
);

View File

@ -0,0 +1,33 @@
import { ADVANCED_FILTER_LOGICAL_OPERATOR_OPTIONS } from '@/object-record/advanced-filter/constants/AdvancedFilterLogicalOperatorOptions';
import { useUpsertCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup';
import { Select } from '@/ui/input/components/Select';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
type AdvancedFilterLogicalOperatorDropdownProps = {
viewFilterGroup: ViewFilterGroup;
};
export const AdvancedFilterLogicalOperatorDropdown = ({
viewFilterGroup,
}: AdvancedFilterLogicalOperatorDropdownProps) => {
const { upsertCombinedViewFilterGroup } = useUpsertCombinedViewFilterGroup();
const handleChange = (value: ViewFilterGroupLogicalOperator) => {
upsertCombinedViewFilterGroup({
...viewFilterGroup,
logicalOperator: value,
});
};
return (
<Select
disableBlur
fullWidth
dropdownId={`advanced-filter-logical-operator-${viewFilterGroup.id}`}
value={viewFilterGroup.logicalOperator}
onChange={handleChange}
options={ADVANCED_FILTER_LOGICAL_OPERATOR_OPTIONS}
/>
);
};

View File

@ -0,0 +1,78 @@
import { AdvancedFilterAddFilterRuleSelect } from '@/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect';
import { AdvancedFilterLogicalOperatorCell } from '@/object-record/advanced-filter/components/AdvancedFilterLogicalOperatorCell';
import { AdvancedFilterRuleOptionsDropdown } from '@/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown';
import { AdvancedFilterViewFilter } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilter';
import { AdvancedFilterViewFilterGroup } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterGroup';
import { useCurrentViewViewFilterGroup } from '@/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-ui';
const StyledRow = styled.div`
display: flex;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledContainer = styled.div<{ isGrayBackground?: boolean }>`
align-items: start;
background-color: ${({ theme, isGrayBackground }) =>
isGrayBackground ? theme.background.transparent.lighter : 'transparent'};
border: ${({ theme }) => `1px solid ${theme.border.color.medium}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
`;
type AdvancedFilterRootLevelViewFilterGroupProps = {
rootLevelViewFilterGroupId: string;
};
export const AdvancedFilterRootLevelViewFilterGroup = ({
rootLevelViewFilterGroupId,
}: AdvancedFilterRootLevelViewFilterGroupProps) => {
const {
currentViewFilterGroup: rootLevelViewFilterGroup,
childViewFiltersAndViewFilterGroups,
lastChildPosition,
} = useCurrentViewViewFilterGroup({
viewFilterGroupId: rootLevelViewFilterGroupId,
});
if (!isDefined(rootLevelViewFilterGroup)) {
return null;
}
return (
<StyledContainer>
{childViewFiltersAndViewFilterGroups.map((child, i) =>
child.__typename === 'ViewFilterGroup' ? (
<StyledRow key={child.id}>
<AdvancedFilterLogicalOperatorCell
index={i}
viewFilterGroup={rootLevelViewFilterGroup}
/>
<AdvancedFilterViewFilterGroup viewFilterGroupId={child.id} />
<AdvancedFilterRuleOptionsDropdown viewFilterGroupId={child.id} />
</StyledRow>
) : (
<StyledRow key={child.id}>
<AdvancedFilterLogicalOperatorCell
index={i}
viewFilterGroup={rootLevelViewFilterGroup}
/>
<AdvancedFilterViewFilter viewFilterId={child.id} />
<AdvancedFilterRuleOptionsDropdown viewFilterId={child.id} />
</StyledRow>
),
)}
<AdvancedFilterAddFilterRuleSelect
viewFilterGroup={rootLevelViewFilterGroup}
lastChildPosition={lastChildPosition}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,87 @@
import { AdvancedFilterRuleOptionsDropdownButton } from '@/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdownButton';
import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter';
import { useCurrentViewViewFilterGroup } from '@/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup';
import { useDeleteCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { isDefined } from 'twenty-ui';
type AdvancedFilterRuleOptionsDropdownProps =
| {
viewFilterId: string;
viewFilterGroupId?: never;
}
| {
viewFilterId?: never;
viewFilterGroupId: string;
};
export const AdvancedFilterRuleOptionsDropdown = ({
viewFilterId,
viewFilterGroupId,
}: AdvancedFilterRuleOptionsDropdownProps) => {
const dropdownId = `advanced-filter-rule-options-${viewFilterId ?? viewFilterGroupId}`;
const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters();
const { deleteCombinedViewFilterGroup } = useDeleteCombinedViewFilterGroup();
const { currentViewFilterGroup, childViewFiltersAndViewFilterGroups } =
useCurrentViewViewFilterGroup({
viewFilterGroupId,
});
const currentViewFilter = useCurrentViewFilter({
viewFilterId,
});
const handleRemove = async () => {
if (isDefined(viewFilterId)) {
deleteCombinedViewFilter(viewFilterId);
const isOnlyViewFilterInGroup =
childViewFiltersAndViewFilterGroups.length === 1;
if (
isOnlyViewFilterInGroup &&
isDefined(currentViewFilter?.viewFilterGroupId)
) {
deleteCombinedViewFilterGroup(currentViewFilter.viewFilterGroupId);
}
} else if (isDefined(currentViewFilterGroup)) {
deleteCombinedViewFilterGroup(currentViewFilterGroup.id);
const childViewFilters = childViewFiltersAndViewFilterGroups.filter(
(child) => child.__typename === 'ViewFilter',
);
for (const childViewFilter of childViewFilters) {
await deleteCombinedViewFilter(childViewFilter.id);
}
} else {
throw new Error('No view filter or view filter group to remove');
}
};
const removeButtonLabel = viewFilterId ? 'Remove rule' : 'Remove rule group';
return (
<Dropdown
disableBlur
dropdownId={dropdownId}
clickableComponent={
<AdvancedFilterRuleOptionsDropdownButton dropdownId={dropdownId} />
}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem text={removeButtonLabel} onClick={handleRemove} />
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
/>
);
};

View File

@ -0,0 +1,25 @@
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { IconButton, IconDotsVertical } from 'twenty-ui';
type AdvancedFilterRuleOptionsDropdownButtonProps = {
dropdownId: string;
};
export const AdvancedFilterRuleOptionsDropdownButton = ({
dropdownId,
}: AdvancedFilterRuleOptionsDropdownButtonProps) => {
const { toggleDropdown } = useDropdown(dropdownId);
const handleClick = () => {
toggleDropdown();
};
return (
<IconButton
aria-label="Filter rule options"
variant="tertiary"
Icon={IconDotsVertical}
onClick={handleClick}
/>
);
};

View File

@ -0,0 +1,47 @@
import { AdvancedFilterViewFilterFieldSelect } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterFieldSelect';
import { AdvancedFilterViewFilterOperandSelect } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterOperandSelect';
import { AdvancedFilterViewFilterValueInput } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterValueInput';
import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
import styled from '@emotion/styled';
const StyledValueDropdownContainer = styled.div`
flex: 3;
`;
const StyledRow = styled.div`
flex: 1;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
overflow: hidden;
`;
type AdvancedFilterViewFilterProps = {
viewFilterId: string;
};
export const AdvancedFilterViewFilter = ({
viewFilterId,
}: AdvancedFilterViewFilterProps) => {
const filter = useCurrentViewFilter({ viewFilterId });
if (!filter) {
return null;
}
return (
<ObjectFilterDropdownScope filterScopeId={filter.id}>
<StyledRow>
<AdvancedFilterViewFilterFieldSelect viewFilterId={filter.id} />
<AdvancedFilterViewFilterOperandSelect viewFilterId={filter.id} />
<StyledValueDropdownContainer>
{configurableViewFilterOperands.has(filter.operand) && (
<AdvancedFilterViewFilterValueInput viewFilterId={filter.id} />
)}
</StyledValueDropdownContainer>
</StyledRow>
</ObjectFilterDropdownScope>
);
};

View File

@ -0,0 +1,71 @@
import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown';
import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter';
import { ObjectFilterDropdownFilterSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect';
import { ObjectFilterDropdownFilterSelectCompositeFieldSubMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
flex: 2;
`;
type AdvancedFilterViewFilterFieldSelectProps = {
viewFilterId: string;
};
export const AdvancedFilterViewFilterFieldSelect = ({
viewFilterId,
}: AdvancedFilterViewFilterFieldSelectProps) => {
const { advancedFilterDropdownId } = useAdvancedFilterDropdown(viewFilterId);
const filter = useCurrentViewFilter({ viewFilterId });
const selectedFieldLabel = filter?.definition.label ?? '';
const { setAdvancedFilterViewFilterGroupId, setAdvancedFilterViewFilterId } =
useFilterDropdown();
const [objectFilterDropdownIsSelectingCompositeField] =
useRecoilComponentStateV2(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const shouldShowCompositeSelectionSubMenu =
objectFilterDropdownIsSelectingCompositeField;
return (
<StyledContainer>
<Dropdown
disableBlur
dropdownId={advancedFilterDropdownId}
clickableComponent={
<SelectControl
selectedOption={{
label: selectedFieldLabel,
value: null,
}}
/>
}
onOpen={() => {
setAdvancedFilterViewFilterId(filter?.id);
setAdvancedFilterViewFilterGroupId(filter?.viewFilterGroupId);
}}
dropdownComponents={
shouldShowCompositeSelectionSubMenu ? (
<ObjectFilterDropdownFilterSelectCompositeFieldSubMenu />
) : (
<ObjectFilterDropdownFilterSelect />
)
}
dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,67 @@
import { AdvancedFilterAddFilterRuleSelect } from '@/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect';
import { AdvancedFilterLogicalOperatorCell } from '@/object-record/advanced-filter/components/AdvancedFilterLogicalOperatorCell';
import { AdvancedFilterRuleOptionsDropdown } from '@/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown';
import { AdvancedFilterViewFilter } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilter';
import { useCurrentViewViewFilterGroup } from '@/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup';
import styled from '@emotion/styled';
const StyledRow = styled.div`
display: flex;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledContainer = styled.div<{ isGrayBackground?: boolean }>`
align-items: start;
background-color: ${({ theme, isGrayBackground }) =>
isGrayBackground ? theme.background.transparent.lighter : 'transparent'};
border: ${({ theme }) => `1px solid ${theme.border.color.medium}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
`;
type AdvancedFilterViewFilterGroupProps = {
viewFilterGroupId: string;
};
export const AdvancedFilterViewFilterGroup = ({
viewFilterGroupId,
}: AdvancedFilterViewFilterGroupProps) => {
const {
currentViewFilterGroup,
childViewFiltersAndViewFilterGroups,
lastChildPosition,
} = useCurrentViewViewFilterGroup({
viewFilterGroupId,
});
if (!currentViewFilterGroup) {
return null;
}
return (
<StyledContainer
isGrayBackground={!!currentViewFilterGroup.parentViewFilterGroupId}
>
{childViewFiltersAndViewFilterGroups.map((child, i) => (
<StyledRow key={child.id}>
<AdvancedFilterLogicalOperatorCell
index={i}
viewFilterGroup={currentViewFilterGroup}
/>
<AdvancedFilterViewFilter viewFilterId={child.id} />
<AdvancedFilterRuleOptionsDropdown viewFilterId={child.id} />
</StyledRow>
))}
<AdvancedFilterAddFilterRuleSelect
viewFilterGroup={currentViewFilterGroup}
lastChildPosition={lastChildPosition}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,111 @@
import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter';
import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-ui';
const StyledContainer = styled.div`
flex: 1;
`;
type AdvancedFilterViewFilterOperandSelectProps = {
viewFilterId: string;
};
export const AdvancedFilterViewFilterOperandSelect = ({
viewFilterId,
}: AdvancedFilterViewFilterOperandSelectProps) => {
const dropdownId = `advanced-filter-view-filter-operand-${viewFilterId}`;
const filter = useCurrentViewFilter({ viewFilterId });
const isDisabled = !filter?.fieldMetadataId;
const { closeDropdown } = useDropdown(dropdownId);
const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters();
const handleOperandChange = (operand: ViewFilterOperand) => {
closeDropdown();
if (!filter) {
throw new Error('Filter is not defined');
}
const { value, displayValue } = getInitialFilterValue(
filter.definition.type,
operand,
filter.value,
filter.displayValue,
);
upsertCombinedViewFilter({
...filter,
operand,
value,
displayValue,
});
};
const operandsForFilterType = isDefined(filter?.definition)
? getOperandsForFilterDefinition(filter.definition)
: [];
if (isDisabled === true) {
return (
<SelectControl
selectedOption={{
label: filter?.operand
? getOperandLabel(filter.operand)
: 'Select operand',
value: null,
}}
isDisabled
/>
);
}
return (
<StyledContainer>
<Dropdown
disableBlur
dropdownId={dropdownId}
clickableComponent={
<SelectControl
selectedOption={{
label: filter.operand
? getOperandLabel(filter.operand)
: 'Select operand',
value: null,
}}
/>
}
dropdownComponents={
<DropdownMenuItemsContainer>
{operandsForFilterType.map((filterOperand, index) => (
<MenuItem
key={`select-filter-operand-${index}`}
onClick={() => {
handleOperandChange(filterOperand);
}}
text={getOperandLabel(filterOperand)}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,70 @@
import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter';
import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
type AdvancedFilterViewFilterValueInputProps = {
viewFilterId: string;
};
export const AdvancedFilterViewFilterValueInput = ({
viewFilterId,
}: AdvancedFilterViewFilterValueInputProps) => {
const dropdownId = `advanced-filter-view-filter-value-input-${viewFilterId}`;
const filter = useCurrentViewFilter({ viewFilterId });
const isDisabled = !filter?.fieldMetadataId || !filter.operand;
const {
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
setIsObjectFilterDropdownOperandSelectUnfolded,
setSelectedFilter,
} = useFilterDropdown();
if (isDisabled) {
return (
<SelectControl
isDisabled
selectedOption={{
label: filter?.displayValue ?? '',
value: null,
}}
/>
);
}
return (
<Dropdown
disableBlur
dropdownId={dropdownId}
clickableComponent={
<SelectControl
selectedOption={{
label: filter?.displayValue ?? '',
value: null,
}}
/>
}
onOpen={() => {
setFilterDefinitionUsedInDropdown(filter.definition);
setSelectedOperandInDropdown(filter.operand);
setIsObjectFilterDropdownOperandSelectUnfolded(true);
setSelectedFilter(filter);
}}
dropdownComponents={
<DropdownMenuItemsContainer>
<ObjectFilterDropdownFilterInput />
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
dropdownMenuWidth={280}
/>
);
};

View File

@ -0,0 +1,12 @@
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
export const ADVANCED_FILTER_LOGICAL_OPERATOR_OPTIONS = [
{
value: ViewFilterGroupLogicalOperator.AND,
label: 'And',
},
{
value: ViewFilterGroupLogicalOperator.OR,
label: 'Or',
},
];

View File

@ -0,0 +1,14 @@
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const useAdvancedFilterDropdown = (viewFilterId?: string) => {
const advancedFilterDropdownId = `advanced-filter-view-filter-field-${viewFilterId}`;
const { closeDropdown: closeAdvancedFilterDropdown } = useDropdown(
advancedFilterDropdownId,
);
return {
closeAdvancedFilterDropdown,
advancedFilterDropdownId,
};
};

View File

@ -0,0 +1,31 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
export const useCurrentViewFilter = ({
viewFilterId,
}: {
viewFilterId?: string;
}) => {
const availableFilterDefinitions = useRecoilComponentValueV2(
availableFilterDefinitionsComponentState,
);
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const viewFilter = currentViewWithCombinedFiltersAndSorts?.viewFilters.find(
(viewFilter) => viewFilter.id === viewFilterId,
);
if (!viewFilter) {
return undefined;
}
const [filter] = mapViewFiltersToFilters(
[viewFilter],
availableFilterDefinitions,
);
return filter;
};

View File

@ -0,0 +1,59 @@
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { isDefined } from 'twenty-ui';
export const useCurrentViewViewFilterGroup = ({
viewFilterGroupId,
}: {
viewFilterGroupId?: string;
}) => {
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const viewFilterGroup =
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.find(
(viewFilterGroup) => viewFilterGroup.id === viewFilterGroupId,
);
if (!isDefined(viewFilterGroup)) {
return {
currentViewFilterGroup: undefined,
childViewFiltersAndViewFilterGroups: [] as (
| ViewFilter
| ViewFilterGroup
)[],
};
}
const childViewFilters =
currentViewWithCombinedFiltersAndSorts?.viewFilters.filter(
(viewFilterToFilter) =>
viewFilterToFilter.viewFilterGroupId === viewFilterGroup.id,
);
const childViewFilterGroups =
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.filter(
(viewFilterGroupToFilter) =>
viewFilterGroupToFilter.parentViewFilterGroupId === viewFilterGroup.id,
);
const childViewFiltersAndViewFilterGroups = [
...(childViewFilterGroups ?? []),
...(childViewFilters ?? []),
].sort((a, b) => {
const positionA = a.positionInViewFilterGroup ?? 0;
const positionB = b.positionInViewFilterGroup ?? 0;
return positionA - positionB;
});
const lastChildPosition =
childViewFiltersAndViewFilterGroups[
childViewFiltersAndViewFilterGroups.length - 1
]?.positionInViewFilterGroup ?? 0;
return {
currentViewFilterGroup: viewFilterGroup,
childViewFiltersAndViewFilterGroups,
lastChildPosition,
};
};

View File

@ -0,0 +1,111 @@
import { useRecoilCallback } from 'recoil';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { isDefined } from '~/utils/isDefined';
export const useDeleteCombinedViewFilterGroup = (
viewBarComponentId?: string,
) => {
const unsavedToUpsertViewFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
viewBarComponentId,
);
const unsavedToDeleteViewFilterGroupIdsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToDeleteViewFilterGroupIdsComponentFamilyState,
viewBarComponentId,
);
const currentViewIdCallbackState = useRecoilComponentCallbackStateV2(
currentViewIdComponentState,
viewBarComponentId,
);
const { getViewFromCache } = useGetViewFromCache();
const deleteCombinedViewFilterGroup = useRecoilCallback(
({ snapshot, set }) =>
async (filterGroupId: string) => {
const currentViewId = getSnapshotValue(
snapshot,
currentViewIdCallbackState,
);
const unsavedToUpsertViewFilterGroups = getSnapshotValue(
snapshot,
unsavedToUpsertViewFilterGroupsCallbackState({
viewId: currentViewId,
}),
);
const unsavedToDeleteViewFilterGroupIds = getSnapshotValue(
snapshot,
unsavedToDeleteViewFilterGroupIdsCallbackState({
viewId: currentViewId,
}),
);
if (!currentViewId) {
return;
}
const currentView = await getViewFromCache(currentViewId);
if (!currentView) {
return;
}
const matchingFilterGroupInCurrentView =
currentView.viewFilterGroups?.find(
(viewFilterGroup) => viewFilterGroup.id === filterGroupId,
);
const matchingFilterGroupInUnsavedFilterGroups =
unsavedToUpsertViewFilterGroups.find(
(viewFilterGroup) => viewFilterGroup.id === filterGroupId,
);
if (isDefined(matchingFilterGroupInUnsavedFilterGroups)) {
set(
unsavedToUpsertViewFilterGroupsCallbackState({
viewId: currentViewId,
}),
unsavedToUpsertViewFilterGroups.filter(
(viewFilterGroup) => viewFilterGroup.id !== filterGroupId,
),
);
}
if (isDefined(matchingFilterGroupInCurrentView)) {
set(
unsavedToDeleteViewFilterGroupIdsCallbackState({
viewId: currentViewId,
}),
[
...new Set([
...unsavedToDeleteViewFilterGroupIds,
matchingFilterGroupInCurrentView.id,
]),
],
);
}
},
[
currentViewIdCallbackState,
getViewFromCache,
unsavedToDeleteViewFilterGroupIdsCallbackState,
unsavedToUpsertViewFilterGroupsCallbackState,
],
);
return {
deleteCombinedViewFilterGroup,
};
};

View File

@ -0,0 +1,54 @@
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { useRecoilCallback } from 'recoil';
export const useUpsertCombinedViewFilterGroup = () => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
ViewComponentInstanceContext,
);
const unsavedToUpsertViewFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
instanceId,
);
const upsertCombinedViewFilterGroup = useRecoilCallback(
({ snapshot, set }) =>
(newViewFilterGroup: Omit<ViewFilterGroup, '__typename'>) => {
const currentViewUnsavedToUpsertViewFilterGroups =
unsavedToUpsertViewFilterGroupsCallbackState({
viewId: newViewFilterGroup.viewId,
});
const unsavedToUpsertViewFilterGroups = getSnapshotValue(
snapshot,
currentViewUnsavedToUpsertViewFilterGroups,
);
const newViewFilterWithTypename: ViewFilterGroup = {
...newViewFilterGroup,
__typename: 'ViewFilterGroup',
};
set(
unsavedToUpsertViewFilterGroupsCallbackState({
viewId: newViewFilterGroup.viewId,
}),
[
...unsavedToUpsertViewFilterGroups.filter(
(viewFilterGroup) => viewFilterGroup.id !== newViewFilterGroup.id,
),
newViewFilterWithTypename,
],
);
},
[unsavedToUpsertViewFilterGroupsCallbackState],
);
return { upsertCombinedViewFilterGroup };
};

View File

@ -0,0 +1,124 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useUpsertCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/components/MenuItemLeftContent';
import { StyledMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
import styled from '@emotion/styled';
import { IconFilter, Pill } from 'twenty-ui';
import { v4 } from 'uuid';
export const StyledContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1)};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const StyledMenuItemSelect = styled(StyledMenuItemBase)`
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
export const StyledPill = styled(Pill)`
background: ${({ theme }) => theme.color.blueAccent10};
color: ${({ theme }) => theme.color.blue};
`;
export const AdvancedFilterButton = () => {
const advancedFilterQuerySubFilterCount = 0; // TODO
const { openDropdown: openAdvancedFilterDropdown } = useDropdown(
ADVANCED_FILTER_DROPDOWN_ID,
);
const { closeDropdown: closeObjectFilterDropdown } = useDropdown(
OBJECT_FILTER_DROPDOWN_ID,
);
const { currentViewId, currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView();
const { upsertCombinedViewFilterGroup } = useUpsertCombinedViewFilterGroup();
const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters();
const objectMetadataId =
currentViewWithCombinedFiltersAndSorts?.objectMetadataId;
if (!objectMetadataId) {
throw new Error('Object metadata id is missing from current view');
}
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId ?? null,
});
const availableFilterDefinitions = useRecoilComponentValueV2(
availableFilterDefinitionsComponentState,
);
const handleClick = () => {
if (!currentViewId) {
throw new Error('Missing current view id');
}
const alreadyHasAdvancedFilterGroup =
(currentViewWithCombinedFiltersAndSorts?.viewFilterGroups?.length ?? 0) >
0;
if (!alreadyHasAdvancedFilterGroup) {
const newViewFilterGroup = {
id: v4(),
viewId: currentViewId,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
};
upsertCombinedViewFilterGroup(newViewFilterGroup);
const defaultFilterDefinition =
availableFilterDefinitions.find(
(filterDefinition) =>
filterDefinition.fieldMetadataId ===
objectMetadataItem?.labelIdentifierFieldMetadataId,
) ?? availableFilterDefinitions?.[0];
if (!defaultFilterDefinition) {
throw new Error('Missing default filter definition');
}
upsertCombinedViewFilter({
id: v4(),
fieldMetadataId: defaultFilterDefinition.fieldMetadataId,
operand: getOperandsForFilterDefinition(defaultFilterDefinition)[0],
definition: defaultFilterDefinition,
value: '',
displayValue: '',
viewFilterGroupId: newViewFilterGroup.id,
});
}
openAdvancedFilterDropdown();
closeObjectFilterDropdown();
};
return (
<StyledContainer>
<StyledMenuItemSelect onClick={handleClick}>
<MenuItemLeftContent LeftIcon={IconFilter} text="Advanced filter" />
{advancedFilterQuerySubFilterCount > 0 && (
<StyledPill label={advancedFilterQuerySubFilterCount.toString()} />
)}
</StyledMenuItemSelect>
</StyledContainer>
);
};

View File

@ -1,7 +1,7 @@
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput';
import { ObjectFilterDropdownFilterSelectCompositeFieldSubMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu';
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';
@ -48,11 +48,13 @@ export const MultipleFiltersDropdownContent = ({
return (
<StyledContainer>
{shoudShowFilterInput ? (
<ObjectFilterDropdownFilterInput filterDropdownId={filterDropdownId} />
<ObjectFilterOperandSelectAndInput
filterDropdownId={filterDropdownId}
/>
) : shouldShowCompositeSelectionSubMenu ? (
<ObjectFilterDropdownFilterSelectCompositeFieldSubMenu />
) : (
<ObjectFilterDropdownFilterSelect />
<ObjectFilterDropdownFilterSelect isAdvancedFilterButtonVisible />
)}
<MultipleFiltersDropdownFilterOnFilterChangedEffect
filterDefinitionUsedInDropdownType={

View File

@ -63,6 +63,7 @@ export const ObjectFilterDropdownDateInput = () => {
: newDate.toLocaleDateString()
: '',
definition: filterDefinitionUsedInDropdown,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
setIsObjectFilterDropdownUnfolded(false);
@ -92,6 +93,7 @@ export const ObjectFilterDropdownDateInput = () => {
operand: selectedOperandInDropdown,
displayValue: getRelativeDateDisplayValue(relativeDate),
definition: filterDefinitionUsedInDropdown,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
setIsObjectFilterDropdownUnfolded(false);

View File

@ -2,8 +2,6 @@ import { useRecoilValue } from 'recoil';
import { ObjectFilterDropdownDateInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput';
import { ObjectFilterDropdownNumberInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput';
import { ObjectFilterDropdownOperandButton } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton';
import { ObjectFilterDropdownOperandSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect';
import { ObjectFilterDropdownOptionSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect';
import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput';
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
@ -14,19 +12,11 @@ import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/
import { isActorSourceCompositeFilter } from '@/object-record/object-filter-dropdown/utils/isActorSourceCompositeFilter';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-ui';
const StyledOperandSelectContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
border-radius: ${({ theme }) => theme.border.radius.md};
left: 10px;
position: absolute;
top: 10px;
width: 100%;
z-index: 1000;
`;
import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes';
import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/NumberFilterTypes';
import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/TextFilterTypes';
type ObjectFilterDropdownFilterInputProps = {
filterDropdownId?: string;
@ -38,13 +28,8 @@ export const ObjectFilterDropdownFilterInput = ({
const {
filterDefinitionUsedInDropdownState,
selectedOperandInDropdownState,
isObjectFilterDropdownOperandSelectUnfoldedState,
} = useFilterDropdown({ filterDropdownId });
const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue(
isObjectFilterDropdownOperandSelectUnfoldedState,
);
const filterDefinitionUsedInDropdown = useRecoilValue(
filterDefinitionUsedInDropdownState,
);
@ -74,40 +59,21 @@ export const ObjectFilterDropdownFilterInput = ({
return (
<>
<ObjectFilterDropdownOperandButton />
{isObjectFilterDropdownOperandSelectUnfolded && (
<StyledOperandSelectContainer>
<ObjectFilterDropdownOperandSelect />
</StyledOperandSelectContainer>
)}
{isConfigurable && selectedOperandInDropdown && (
<>
{[
'TEXT',
'EMAIL',
'EMAILS',
'PHONE',
'FULL_NAME',
'LINK',
'LINKS',
'ADDRESS',
'ACTOR',
'ARRAY',
'RAW_JSON',
'PHONES',
].includes(filterDefinitionUsedInDropdown.type) &&
{TEXT_FILTER_TYPES.includes(filterDefinitionUsedInDropdown.type) &&
!isActorSourceCompositeFilter(filterDefinitionUsedInDropdown) && (
<ObjectFilterDropdownTextSearchInput />
)}
{['NUMBER', 'CURRENCY'].includes(
{NUMBER_FILTER_TYPES.includes(
filterDefinitionUsedInDropdown.type,
) && <ObjectFilterDropdownNumberInput />}
{filterDefinitionUsedInDropdown.type === 'RATING' && (
<ObjectFilterDropdownRatingInput />
)}
{['DATE_TIME', 'DATE'].includes(
filterDefinitionUsedInDropdown.type,
) && <ObjectFilterDropdownDateInput />}
{DATE_FILTER_TYPES.includes(filterDefinitionUsedInDropdown.type) && (
<ObjectFilterDropdownDateInput />
)}
{filterDefinitionUsedInDropdown.type === 'RELATION' && (
<>
<ObjectFilterDropdownSearchInput />

View File

@ -0,0 +1,40 @@
import { ObjectFilterDropdownOperandButton } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton';
import { ObjectFilterDropdownOperandSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
const StyledOperandSelectContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
border-radius: ${({ theme }) => theme.border.radius.md};
left: 10px;
position: absolute;
top: 10px;
width: 100%;
z-index: 1000;
`;
export const ObjectFilterDropdownFilterOperandSelect = ({
filterDropdownId,
}: {
filterDropdownId?: string;
}) => {
const { isObjectFilterDropdownOperandSelectUnfoldedState } =
useFilterDropdown({ filterDropdownId });
const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue(
isObjectFilterDropdownOperandSelectUnfoldedState,
);
return (
<>
<ObjectFilterDropdownOperandButton />
{isObjectFilterDropdownOperandSelectUnfolded && (
<StyledOperandSelectContainer>
<ObjectFilterDropdownOperandSelect />
</StyledOperandSelectContainer>
)}
</>
);
};

View File

@ -3,6 +3,8 @@ import { useContext } from 'react';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown';
import { AdvancedFilterButton } from '@/object-record/object-filter-dropdown/components/AdvancedFilterButton';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
@ -15,6 +17,7 @@ import { SelectableItem } from '@/ui/layout/selectable-list/components/Selectabl
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
@ -45,12 +48,27 @@ export const StyledInput = styled.input`
}
`;
export const ObjectFilterDropdownFilterSelect = () => {
type ObjectFilterDropdownFilterSelectProps = {
isAdvancedFilterButtonVisible?: boolean;
};
export const ObjectFilterDropdownFilterSelect = ({
isAdvancedFilterButtonVisible,
}: ObjectFilterDropdownFilterSelectProps) => {
const {
setObjectFilterDropdownSearchInput,
objectFilterDropdownSearchInputState,
advancedFilterViewFilterIdState,
} = useFilterDropdown();
const advancedFilterViewFilterId = useRecoilValue(
advancedFilterViewFilterIdState,
);
const { closeAdvancedFilterDropdown } = useAdvancedFilterDropdown(
advancedFilterViewFilterId,
);
const objectFilterDropdownSearchInput = useRecoilValue(
objectFilterDropdownSearchInputState,
);
@ -110,14 +128,22 @@ export const ObjectFilterDropdownFilterSelect = () => {
}
resetSelectedItem();
selectFilter({ filterDefinition: selectedFilterDefinition });
closeAdvancedFilterDropdown();
};
const shoudShowSeparator =
visibleColumnsFilterDefinitions.length > 0 &&
hiddenColumnsFilterDefinitions.length > 0;
const { currentViewId, currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView();
const shouldShowAdvancedFilterButton =
isDefined(currentViewId) &&
isDefined(currentViewWithCombinedFiltersAndSorts?.objectMetadataId) &&
isAdvancedFilterButtonVisible;
return (
<>
<StyledInput
@ -164,6 +190,7 @@ export const ObjectFilterDropdownFilterSelect = () => {
)}
</DropdownMenuItemsContainer>
</SelectableList>
{shouldShowAdvancedFilterButton && <AdvancedFilterButton />}
</>
);
};

View File

@ -1,3 +1,4 @@
import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { objectFilterDropdownFirstLevelFilterDefinitionComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFirstLevelFilterDefinitionComponentState';
@ -6,6 +7,7 @@ import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-rec
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { getFilterableFieldTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel';
import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue';
import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
@ -13,6 +15,7 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { IconApps, IconChevronLeft, isDefined, useIcons } from 'twenty-ui';
export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => {
@ -47,10 +50,46 @@ export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => {
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
setObjectFilterDropdownSearchInput,
selectFilter,
advancedFilterViewFilterIdState,
advancedFilterViewFilterGroupIdState,
} = useFilterDropdown();
const advancedFilterViewFilterId = useRecoilValue(
advancedFilterViewFilterIdState,
);
const advancedFilterViewFilterGroupId = useRecoilValue(
advancedFilterViewFilterGroupIdState,
);
const { closeAdvancedFilterDropdown } = useAdvancedFilterDropdown(
advancedFilterViewFilterId,
);
const handleSelectFilter = (definition: FilterDefinition | null) => {
if (definition !== null) {
if (
isDefined(advancedFilterViewFilterId) &&
isDefined(advancedFilterViewFilterGroupId)
) {
closeAdvancedFilterDropdown();
const operand = getOperandsForFilterDefinition(definition)[0];
const { value, displayValue } = getInitialFilterValue(
definition.type,
operand,
);
selectFilter({
id: advancedFilterViewFilterId,
fieldMetadataId: definition.fieldMetadataId,
value,
operand,
displayValue,
definition,
viewFilterGroupId: advancedFilterViewFilterGroupId,
});
}
setFilterDefinitionUsedInDropdown(definition);
setSelectedOperandInDropdown(

View File

@ -1,3 +1,4 @@
import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown';
import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { useSelectFilter } from '@/object-record/object-filter-dropdown/hooks/useSelectFilter';
@ -59,11 +60,23 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
setObjectFilterDropdownSearchInput,
advancedFilterViewFilterIdState,
} = useFilterDropdown();
const setHotkeyScope = useSetHotkeyScope();
const advancedFilterViewFilterId = useRecoilValue(
advancedFilterViewFilterIdState,
);
const { closeAdvancedFilterDropdown } = useAdvancedFilterDropdown(
advancedFilterViewFilterId,
);
const handleSelectFilter = (availableFilterDefinition: FilterDefinition) => {
closeAdvancedFilterDropdown();
selectFilter({ filterDefinition: availableFilterDefinition });
setFilterDefinitionUsedInDropdown(availableFilterDefinition);
if (
@ -87,8 +100,6 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
const handleClick = () => {
resetSelectedItem();
selectFilter({ filterDefinition });
if (isACompositeField) {
// TODO: create isCompositeFilterableFieldType type guard
setObjectFilterDropdownSubMenuFieldType(

View File

@ -56,6 +56,7 @@ export const ObjectFilterDropdownNumberInput = () => {
operand: selectedOperandInDropdown,
displayValue: newValue,
definition: filterDefinitionUsedInDropdown,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
}}
/>

View File

@ -135,6 +135,7 @@ export const ObjectFilterDropdownOptionSelect = () => {
displayValue: filterDisplayValue,
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: newFilterValue,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
}
resetSelectedItem();

View File

@ -64,6 +64,7 @@ export const ObjectFilterDropdownRatingInput = () => {
operand: selectedOperandInDropdown,
displayValue: convertFieldRatingValueToNumber(newValue),
definition: filterDefinitionUsedInDropdown,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
}}
/>

View File

@ -129,6 +129,7 @@ export const ObjectFilterDropdownRecordSelect = ({
displayValue: filterDisplayValue,
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: newFilterValue,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
}
};

View File

@ -115,6 +115,7 @@ export const ObjectFilterDropdownSourceSelect = ({
displayValue: filterDisplayValue,
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: newFilterValue,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
}
};

View File

@ -52,12 +52,13 @@ export const ObjectFilterDropdownTextSearchInput = () => {
setObjectFilterDropdownSearchInput(event.target.value);
selectFilter?.({
id: selectedFilter?.id ? selectedFilter.id : filterId,
id: selectedFilter?.id ?? filterId,
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: event.target.value,
operand: selectedOperandInDropdown,
displayValue: event.target.value,
definition: filterDefinitionUsedInDropdown,
viewFilterGroupId: selectedFilter?.viewFilterGroupId,
});
}}
/>

View File

@ -0,0 +1,19 @@
import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput';
import { ObjectFilterDropdownFilterOperandSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterOperandSelect';
type ObjectFilterOperandSelectAndInputProps = {
filterDropdownId?: string;
};
export const ObjectFilterOperandSelectAndInput = ({
filterDropdownId,
}: ObjectFilterOperandSelectAndInputProps) => {
return (
<>
<ObjectFilterDropdownFilterOperandSelect
filterDropdownId={filterDropdownId}
/>
<ObjectFilterDropdownFilterInput filterDropdownId={filterDropdownId} />
</>
);
};

View File

@ -0,0 +1 @@
export const DATE_FILTER_TYPES = ['DATE_TIME', 'DATE'];

View File

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

View File

@ -0,0 +1,14 @@
export const TEXT_FILTER_TYPES = [
'TEXT',
'EMAIL',
'EMAILS',
'PHONE',
'FULL_NAME',
'LINK',
'LINKS',
'ADDRESS',
'ACTOR',
'ARRAY',
'RAW_JSON',
'PHONES',
];

View File

@ -8,11 +8,24 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { MockedProvider } from '@apollo/client/testing';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
const filterDropdownId = 'filterDropdownId';
const renderHookConfig = {
wrapper: RecoilRoot,
wrapper: ({ children }: any) => (
<RecoilRoot>
<MockedProvider mocks={[]} addTypename={false}>
<JestObjectMetadataItemSetter>
<ViewComponentInstanceContext.Provider value={{ instanceId: 'test' }}>
{children}
</ViewComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</MockedProvider>
</RecoilRoot>
),
};
const filterDefinitions: FilterDefinition[] = [
@ -306,9 +319,10 @@ describe('useFilterDropdown', () => {
it('should reset filter', async () => {
const { result } = renderHook(() => {
const { selectFilter, resetFilter } = useFilterDropdown({
const { resetFilter, selectFilter } = useFilterDropdown({
filterDropdownId,
});
const { selectedFilterState } = useFilterDropdownStates(filterDropdownId);
const [selectedFilter, setSelectedFilter] =

View File

@ -7,11 +7,14 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters';
import { isDefined } from 'twenty-ui';
import { ObjectFilterDropdownScopeInternalContext } from '../scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext';
import { Filter } from '../types/Filter';
type UseFilterDropdownProps = {
filterDropdownId?: string;
advancedFilterViewFilterId?: string;
};
export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
@ -30,17 +33,25 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
selectedFilterState,
selectedOperandInDropdownState,
onFilterSelectState,
advancedFilterViewFilterGroupIdState,
advancedFilterViewFilterIdState,
} = useFilterDropdownStates(scopeId);
const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters();
const selectFilter = useRecoilCallback(
({ set, snapshot }) =>
(filter: Filter | null) => {
set(selectedFilterState, filter);
const onFilterSelect = getSnapshotValue(snapshot, onFilterSelectState);
if (isDefined(filter)) {
upsertCombinedViewFilter(filter);
}
onFilterSelect?.(filter);
},
[selectedFilterState, onFilterSelectState],
[selectedFilterState, onFilterSelectState, upsertCombinedViewFilter],
);
const emptyFilterButKeepDefinition = useRecoilCallback(
@ -117,6 +128,12 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
isObjectFilterDropdownUnfoldedState,
);
const setOnFilterSelect = useSetRecoilState(onFilterSelectState);
const setAdvancedFilterViewFilterGroupId = useSetRecoilState(
advancedFilterViewFilterGroupIdState,
);
const setAdvancedFilterViewFilterId = useSetRecoilState(
advancedFilterViewFilterIdState,
);
return {
scopeId,
@ -132,6 +149,8 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
setIsObjectFilterDropdownOperandSelectUnfolded,
setIsObjectFilterDropdownUnfolded,
setOnFilterSelect,
setAdvancedFilterViewFilterGroupId,
setAdvancedFilterViewFilterId,
emptyFilterButKeepDefinition,
filterDefinitionUsedInDropdownState,
objectFilterDropdownSearchInputState,
@ -143,5 +162,7 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
selectedFilterState,
selectedOperandInDropdownState,
onFilterSelectState,
advancedFilterViewFilterGroupIdState,
advancedFilterViewFilterIdState,
};
};

View File

@ -1,3 +1,5 @@
import { advancedFilterViewFilterGroupIdComponentState } from '@/object-record/object-filter-dropdown/states/advancedFilterViewFilterGroupIdComponentState';
import { advancedFilterViewFilterIdComponentState } from '@/object-record/object-filter-dropdown/states/advancedFilterViewFilterIdComponentState';
import { filterDefinitionUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState';
import { isObjectFilterDropdownOperandSelectUnfoldedComponentState } from '@/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedComponentState';
import { isObjectFilterDropdownUnfoldedComponentState } from '@/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedComponentState';
@ -56,6 +58,16 @@ export const useFilterDropdownStates = (scopeId: string) => {
scopeId,
);
const advancedFilterViewFilterGroupIdState = extractComponentState(
advancedFilterViewFilterGroupIdComponentState,
scopeId,
);
const advancedFilterViewFilterIdState = extractComponentState(
advancedFilterViewFilterIdComponentState,
scopeId,
);
return {
filterDefinitionUsedInDropdownState,
objectFilterDropdownSearchInputState,
@ -66,5 +78,7 @@ export const useFilterDropdownStates = (scopeId: string) => {
selectedFilterState,
selectedOperandInDropdownState,
onFilterSelectState,
advancedFilterViewFilterGroupIdState,
advancedFilterViewFilterIdState,
};
};

View File

@ -4,6 +4,8 @@ import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/ut
import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
import { v4 } from 'uuid';
type SelectFilterParams = {
@ -16,8 +18,17 @@ export const useSelectFilter = () => {
setSelectedOperandInDropdown,
setObjectFilterDropdownSearchInput,
selectFilter: filterDropdownSelectFilter,
advancedFilterViewFilterGroupIdState,
advancedFilterViewFilterIdState,
} = useFilterDropdown();
const advancedFilterViewFilterId = useRecoilValue(
advancedFilterViewFilterIdState,
);
const advancedFilterViewFilterGroupId = useRecoilValue(
advancedFilterViewFilterGroupIdState,
);
const setHotkeyScope = useSetHotkeyScope();
const selectFilter = ({ filterDefinition }: SelectFilterParams) => {
@ -39,14 +50,17 @@ export const useSelectFilter = () => {
getOperandsForFilterDefinition(filterDefinition)[0],
);
if (value !== '') {
const isAdvancedFilter = isDefined(advancedFilterViewFilterId);
if (isAdvancedFilter || value !== '') {
filterDropdownSelectFilter({
id: v4(),
id: advancedFilterViewFilterId ?? v4(),
fieldMetadataId: filterDefinition.fieldMetadataId,
displayValue,
operand: getOperandsForFilterDefinition(filterDefinition)[0],
value,
definition: filterDefinition,
viewFilterGroupId: advancedFilterViewFilterGroupId,
});
}

View File

@ -0,0 +1,7 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const advancedFilterViewFilterGroupIdComponentState =
createComponentState<string | undefined>({
key: 'advancedFilterViewFilterGroupIdComponentState',
defaultValue: undefined,
});

View File

@ -0,0 +1,8 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const advancedFilterViewFilterIdComponentState = createComponentState<
string | undefined
>({
key: 'advancedFilterViewFilterIdComponentState',
defaultValue: undefined,
});

View File

@ -7,7 +7,9 @@ export type Filter = {
fieldMetadataId: string;
value: string;
displayValue: string;
viewFilterGroupId?: string;
displayAvatarUrl?: string;
operand: ViewFilterOperand;
positionInViewFilterGroup?: number | null;
definition: FilterDefinition;
};

View File

@ -0,0 +1,4 @@
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
export type FilterDraft = Partial<Filter> &
Omit<Filter, 'fieldMetadataId' | 'operand' | 'definition'>;

View File

@ -0,0 +1,14 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
export const configurableViewFilterOperands = new Set<ViewFilterOperand>([
ViewFilterOperand.Is,
ViewFilterOperand.IsNotNull,
ViewFilterOperand.IsNot,
ViewFilterOperand.LessThan,
ViewFilterOperand.GreaterThan,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
ViewFilterOperand.IsRelative,
]);

View File

@ -3,7 +3,7 @@ import { isActorSourceCompositeFilter } from '@/object-record/object-filter-drop
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
export const getOperandsForFilterDefinition = (
filterDefinition: FilterDefinition,
filterDefinition: Pick<FilterDefinition, 'type' | 'compositeFieldName'>,
): ViewFilterOperand[] => {
const emptyOperands = [
ViewFilterOperand.IsEmpty,

View File

@ -2,7 +2,7 @@ import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/F
import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata';
export const isActorSourceCompositeFilter = (
filterDefinition: FilterDefinition,
filterDefinition: Pick<FilterDefinition, 'compositeFieldName'>,
) => {
return (
filterDefinition.compositeFieldName ===

View File

@ -1,5 +1,5 @@
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
@ -16,7 +16,7 @@ const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
jest.useFakeTimers().setSystemTime(new Date('2020-01-01'));
describe('turnFiltersIntoQueryFilter', () => {
describe('computeViewRecordGqlOperationFilter', () => {
it('should work as expected for single filter', () => {
const companyMockNameFieldMetadataId =
companyMockObjectMetadataItem.fields.find(
@ -37,9 +37,10 @@ describe('turnFiltersIntoQueryFilter', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[nameFilter],
companyMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({
@ -88,9 +89,10 @@ describe('turnFiltersIntoQueryFilter', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[nameFilter, employeesFilter],
companyMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({
@ -173,7 +175,7 @@ describe('should work as expected for the different field types', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[
addressFilterContains,
addressFilterDoesNotContain,
@ -181,6 +183,7 @@ describe('should work as expected for the different field types', () => {
addressFilterIsNotEmpty,
],
companyMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({
@ -554,7 +557,7 @@ describe('should work as expected for the different field types', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[
phonesFilterContains,
phonesFilterDoesNotContain,
@ -562,6 +565,7 @@ describe('should work as expected for the different field types', () => {
phonesFilterIsNotEmpty,
],
personMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({
@ -754,7 +758,7 @@ describe('should work as expected for the different field types', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[
emailsFilterContains,
emailsFilterDoesNotContain,
@ -762,6 +766,7 @@ describe('should work as expected for the different field types', () => {
emailsFilterIsNotEmpty,
],
personMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({
@ -908,7 +913,7 @@ describe('should work as expected for the different field types', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[
dateFilterIsAfter,
dateFilterIsBefore,
@ -917,6 +922,7 @@ describe('should work as expected for the different field types', () => {
dateFilterIsNotEmpty,
],
companyMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({
@ -1023,7 +1029,7 @@ describe('should work as expected for the different field types', () => {
},
};
const result = turnFiltersIntoQueryFilter(
const result = computeViewRecordGqlOperationFilter(
[
employeesFilterIsGreaterThan,
employeesFilterIsLessThan,
@ -1031,6 +1037,7 @@ describe('should work as expected for the different field types', () => {
employeesFilterIsNotEmpty,
],
companyMockObjectMetadataItem.fields,
[],
);
expect(result).toEqual({

View File

@ -0,0 +1,903 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
ActorFilter,
AddressFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
StringFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
import { isDefined } from '~/utils/isDefined';
import {
convertGreaterThanRatingToArrayOfRatingValues,
convertLessThanRatingToArrayOfRatingValues,
convertRatingToRatingValue,
} from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { getEmptyRecordGqlOperationFilter } from '@/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
import { resolveFilterValue } from '@/views/view-filter-value/utils/resolveFilterValue';
import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns';
import { z } from 'zod';
const computeFilterRecordGqlOperationFilter = (
filter: Filter,
fields: Pick<Field, 'id' | 'name'>[],
): RecordGqlOperationFilter | undefined => {
const correspondingField = fields.find(
(field) => field.id === filter.fieldMetadataId,
);
const compositeFieldName = filter.definition.compositeFieldName;
const isCompositeFieldFiter = isNonEmptyString(compositeFieldName);
const isEmptyOperand = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
].includes(filter.operand);
if (!correspondingField) {
return;
}
if (!isEmptyOperand) {
if (!isDefined(filter.value) || filter.value === '') {
return;
}
}
switch (filter.definition.type) {
case 'TEXT':
switch (filter.operand) {
case ViewFilterOperand.Contains:
return {
[correspondingField.name]: {
ilike: `%${filter.value}%`,
} as StringFilter,
};
case ViewFilterOperand.DoesNotContain:
return {
not: {
[correspondingField.name]: {
ilike: `%${filter.value}%`,
} as StringFilter,
},
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'RAW_JSON':
switch (filter.operand) {
case ViewFilterOperand.Contains:
return {
[correspondingField.name]: {
like: `%${filter.value}%`,
} as RawJsonFilter,
};
case ViewFilterOperand.DoesNotContain:
return {
not: {
[correspondingField.name]: {
like: `%${filter.value}%`,
} as RawJsonFilter,
},
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'DATE':
case 'DATE_TIME': {
const resolvedFilterValue = resolveFilterValue(filter);
const now = roundToNearestMinutes(new Date());
const date =
resolvedFilterValue instanceof Date ? resolvedFilterValue : now;
switch (filter.operand) {
case ViewFilterOperand.IsAfter: {
return {
[correspondingField.name]: {
gt: date.toISOString(),
} as DateFilter,
};
}
case ViewFilterOperand.IsBefore: {
return {
[correspondingField.name]: {
lt: date.toISOString(),
} as DateFilter,
};
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty: {
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
}
case ViewFilterOperand.IsRelative: {
const dateRange = z
.object({ start: z.date(), end: z.date() })
.safeParse(resolvedFilterValue).data;
const defaultDateRange = resolveFilterValue({
value: 'PAST_1_DAY',
definition: {
type: 'DATE',
},
operand: ViewFilterOperand.IsRelative,
});
if (!defaultDateRange) {
throw new Error('Failed to resolve default date range');
}
const { start, end } = dateRange ?? defaultDateRange;
return {
and: [
{
[correspondingField.name]: {
gte: start.toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
lte: end.toISOString(),
} as DateFilter,
},
],
};
}
case ViewFilterOperand.Is: {
const isValid = resolvedFilterValue instanceof Date;
const date = isValid ? resolvedFilterValue : now;
return {
and: [
{
[correspondingField.name]: {
lte: endOfDay(date).toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
gte: startOfDay(date).toISOString(),
} as DateFilter,
},
],
};
}
case ViewFilterOperand.IsInPast:
return {
[correspondingField.name]: {
lte: now.toISOString(),
} as DateFilter,
};
case ViewFilterOperand.IsInFuture:
return {
[correspondingField.name]: {
gte: now.toISOString(),
} as DateFilter,
};
case ViewFilterOperand.IsToday: {
return {
and: [
{
[correspondingField.name]: {
lte: endOfDay(now).toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
gte: startOfDay(now).toISOString(),
} as DateFilter,
},
],
};
}
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`, //
);
}
}
case 'RATING':
switch (filter.operand) {
case ViewFilterOperand.Is:
return {
[correspondingField.name]: {
eq: convertRatingToRatingValue(parseFloat(filter.value)),
} as StringFilter,
};
case ViewFilterOperand.GreaterThan:
return {
[correspondingField.name]: {
in: convertGreaterThanRatingToArrayOfRatingValues(
parseFloat(filter.value),
),
} as StringFilter,
};
case ViewFilterOperand.LessThan:
return {
[correspondingField.name]: {
in: convertLessThanRatingToArrayOfRatingValues(
parseFloat(filter.value),
),
} as StringFilter,
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'NUMBER':
switch (filter.operand) {
case ViewFilterOperand.GreaterThan:
return {
[correspondingField.name]: {
gte: parseFloat(filter.value),
} as FloatFilter,
};
case ViewFilterOperand.LessThan:
return {
[correspondingField.name]: {
lte: parseFloat(filter.value),
} as FloatFilter,
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'RELATION': {
if (!isEmptyOperand) {
try {
JSON.parse(filter.value);
} catch (e) {
throw new Error(
`Cannot parse filter value for RELATION filter : "${filter.value}"`,
);
}
const parsedRecordIds = JSON.parse(filter.value) as string[];
if (parsedRecordIds.length > 0) {
switch (filter.operand) {
case ViewFilterOperand.Is:
return {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
};
case ViewFilterOperand.IsNot:
if (parsedRecordIds.length > 0) {
return {
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
},
};
}
break;
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
}
} else {
switch (filter.operand) {
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown empty operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
}
break;
}
case 'CURRENCY':
switch (filter.operand) {
case ViewFilterOperand.GreaterThan:
return {
[correspondingField.name]: {
amountMicros: { gte: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter,
};
case ViewFilterOperand.LessThan:
return {
[correspondingField.name]: {
amountMicros: { lte: parseFloat(filter.value) * 1000000 },
} as CurrencyFilter,
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'LINKS': {
const linksFilters = generateILikeFiltersForCompositeFields(
filter.value,
correspondingField.name,
['primaryLinkLabel', 'primaryLinkUrl'],
);
switch (filter.operand) {
case ViewFilterOperand.Contains:
if (!isCompositeFieldFiter) {
return {
or: linksFilters,
};
} else {
return {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${filter.value}%`,
},
},
};
}
case ViewFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) {
return {
and: linksFilters.map((filter) => {
return {
not: filter,
};
}),
};
} else {
return {
not: {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${filter.value}%`,
},
},
},
};
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
}
case 'FULL_NAME': {
const fullNameFilters = generateILikeFiltersForCompositeFields(
filter.value,
correspondingField.name,
['firstName', 'lastName'],
);
switch (filter.operand) {
case ViewFilterOperand.Contains:
if (!isCompositeFieldFiter) {
return {
or: fullNameFilters,
};
} else {
return {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${filter.value}%`,
},
},
};
}
case ViewFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) {
return {
and: fullNameFilters.map((filter) => {
return {
not: filter,
};
}),
};
} else {
return {
not: {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${filter.value}%`,
},
},
},
};
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
}
case 'ADDRESS':
switch (filter.operand) {
case ViewFilterOperand.Contains:
if (!isCompositeFieldFiter) {
return {
or: [
{
[correspondingField.name]: {
addressStreet1: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressStreet2: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCity: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressState: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCountry: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressPostcode: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
],
};
} else {
return {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${filter.value}%`,
} as AddressFilter,
},
};
}
case ViewFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) {
return {
and: [
{
not: {
[correspondingField.name]: {
addressStreet1: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
},
{
not: {
[correspondingField.name]: {
addressStreet2: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
},
{
not: {
[correspondingField.name]: {
addressCity: {
ilike: `%${filter.value}%`,
},
} as AddressFilter,
},
},
],
};
} else {
return {
not: {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${filter.value}%`,
} as AddressFilter,
},
},
};
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'SELECT': {
if (isEmptyOperand) {
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
}
const stringifiedSelectValues = filter.value;
let parsedOptionValues: string[] = [];
if (!isNonEmptyString(stringifiedSelectValues)) {
break;
}
try {
parsedOptionValues = JSON.parse(stringifiedSelectValues);
} catch (e) {
throw new Error(
`Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`,
);
}
if (parsedOptionValues.length > 0) {
switch (filter.operand) {
case ViewFilterOperand.Is:
return {
[correspondingField.name]: {
in: parsedOptionValues,
} as UUIDFilter,
};
case ViewFilterOperand.IsNot:
return {
not: {
[correspondingField.name]: {
in: parsedOptionValues,
} as UUIDFilter,
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
}
break;
}
// TODO: fix this with a new composite field in ViewFilter entity
case 'ACTOR': {
switch (filter.operand) {
case ViewFilterOperand.Is: {
const parsedRecordIds = JSON.parse(filter.value) as string[];
return {
[correspondingField.name]: {
source: {
in: parsedRecordIds,
} as RelationFilter,
},
};
}
case ViewFilterOperand.IsNot: {
const parsedRecordIds = JSON.parse(filter.value) as string[];
if (parsedRecordIds.length > 0) {
return {
not: {
[correspondingField.name]: {
source: {
in: parsedRecordIds,
} as RelationFilter,
},
},
};
}
break;
}
case ViewFilterOperand.Contains:
return {
or: [
{
[correspondingField.name]: {
name: {
ilike: `%${filter.value}%`,
},
} as ActorFilter,
},
],
};
case ViewFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingField.name]: {
name: {
ilike: `%${filter.value}%`,
},
} as ActorFilter,
},
},
],
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.label} filter`,
);
}
break;
}
case 'EMAILS':
switch (filter.operand) {
case ViewFilterOperand.Contains:
return {
or: [
{
[correspondingField.name]: {
primaryEmail: {
ilike: `%${filter.value}%`,
},
} as EmailsFilter,
},
],
};
case ViewFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingField.name]: {
primaryEmail: {
ilike: `%${filter.value}%`,
},
} as EmailsFilter,
},
},
],
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
case 'PHONES': {
const phonesFilters = generateILikeFiltersForCompositeFields(
filter.value,
correspondingField.name,
['primaryPhoneNumber', 'primaryPhoneCountryCode'],
);
switch (filter.operand) {
case ViewFilterOperand.Contains:
return {
or: phonesFilters,
};
case ViewFilterOperand.DoesNotContain:
return {
and: phonesFilters.map((filter) => {
return {
not: filter,
};
}),
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
return getEmptyRecordGqlOperationFilter(
filter.operand,
correspondingField,
filter.definition,
);
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
}
default:
throw new Error('Unknown filter type');
}
};
const computeViewFilterGroupRecordGqlOperationFilter = (
filters: Filter[],
fields: Pick<Field, 'id' | 'name'>[],
viewFilterGroups: ViewFilterGroup[],
currentViewFilterGroupId?: string,
): RecordGqlOperationFilter | undefined => {
const currentViewFilterGroup = viewFilterGroups.find(
(viewFilterGroup) => viewFilterGroup.id === currentViewFilterGroupId,
);
if (!currentViewFilterGroup) {
return undefined;
}
const groupFilters = filters.filter(
(filter) => filter.viewFilterGroupId === currentViewFilterGroupId,
);
const groupRecordGqlOperationFilters = groupFilters
.map((filter) => computeFilterRecordGqlOperationFilter(filter, fields))
.filter(isDefined);
const subGroupRecordGqlOperationFilters = viewFilterGroups
.filter(
(viewFilterGroup) =>
viewFilterGroup.parentViewFilterGroupId === currentViewFilterGroupId,
)
.map((subViewFilterGroup) =>
computeViewFilterGroupRecordGqlOperationFilter(
filters,
fields,
viewFilterGroups,
subViewFilterGroup.id,
),
)
.filter(isDefined);
if (
currentViewFilterGroup.logicalOperator ===
ViewFilterGroupLogicalOperator.AND
) {
return {
and: [
...groupRecordGqlOperationFilters,
...subGroupRecordGqlOperationFilters,
],
};
} else if (
currentViewFilterGroup.logicalOperator === ViewFilterGroupLogicalOperator.OR
) {
return {
or: [
...groupRecordGqlOperationFilters,
...subGroupRecordGqlOperationFilters,
],
};
} else {
throw new Error(
`Unknown logical operator ${currentViewFilterGroup.logicalOperator}`,
);
}
};
export const computeViewRecordGqlOperationFilter = (
filters: Filter[],
fields: Pick<Field, 'id' | 'name'>[],
viewFilterGroups: ViewFilterGroup[],
): RecordGqlOperationFilter => {
const regularRecordGqlOperationFilter: RecordGqlOperationFilter[] = filters
.filter((filter) => !filter.viewFilterGroupId)
.map((regularFilter) =>
computeFilterRecordGqlOperationFilter(regularFilter, fields),
)
.filter(isDefined);
const outermostFilterGroupId = viewFilterGroups.find(
(viewFilterGroup) => !viewFilterGroup.parentViewFilterGroupId,
)?.id;
const advancedRecordGqlOperationFilter =
computeViewFilterGroupRecordGqlOperationFilter(
filters,
fields,
viewFilterGroups,
outermostFilterGroupId,
);
const recordGqlOperationFilters = [
...regularRecordGqlOperationFilter,
advancedRecordGqlOperationFilter,
].filter(isDefined);
if (recordGqlOperationFilters.length === 0) {
return {};
}
if (recordGqlOperationFilters.length === 1) {
return recordGqlOperationFilters[0];
}
const recordGqlOperationFilter = {
and: recordGqlOperationFilters,
};
return recordGqlOperationFilter;
};

View File

@ -19,11 +19,9 @@ import { isNonEmptyString } from '@sniptt/guards';
import { Field } from '~/generated/graphql';
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
// TODO: fix this
export const applyEmptyFilters = (
export const getEmptyRecordGqlOperationFilter = (
operand: ViewFilterOperand,
correspondingField: Pick<Field, 'id' | 'name'>,
objectRecordFilters: RecordGqlOperationFilter[],
definition: FilterDefinition,
) => {
let emptyRecordFilter: RecordGqlOperationFilter = {};
@ -332,13 +330,11 @@ export const applyEmptyFilters = (
switch (operand) {
case ViewFilterOperand.IsEmpty:
objectRecordFilters.push(emptyRecordFilter);
break;
return emptyRecordFilter;
case ViewFilterOperand.IsNotEmpty:
objectRecordFilters.push({
return {
not: emptyRecordFilter,
});
break;
};
default:
throw new Error(
`Unknown operand ${operand} for ${definition.type} filter`,

View File

@ -1,913 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
ActorFilter,
AddressFilter,
ArrayFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
StringFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields';
import { isDefined } from '~/utils/isDefined';
import {
convertGreaterThanRatingToArrayOfRatingValues,
convertLessThanRatingToArrayOfRatingValues,
convertRatingToRatingValue,
} from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { applyEmptyFilters } from '@/object-record/record-filter/utils/applyEmptyFilters';
import { resolveFilterValue } from '@/views/view-filter-value/utils/resolveFilterValue';
import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns';
import { z } from 'zod';
// TODO: break this down into smaller functions and make the whole thing immutable
// Especially applyEmptyFilters
export const turnFiltersIntoQueryFilter = (
rawUIFilters: Filter[],
fields: Pick<Field, 'id' | 'name'>[],
): RecordGqlOperationFilter | undefined => {
const objectRecordFilters: RecordGqlOperationFilter[] = [];
for (const rawUIFilter of rawUIFilters) {
const correspondingField = fields.find(
(field) => field.id === rawUIFilter.fieldMetadataId,
);
const compositeFieldName = rawUIFilter.definition.compositeFieldName;
const isCompositeFieldFiter = isNonEmptyString(compositeFieldName);
const isEmptyOperand = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
ViewFilterOperand.IsInPast,
ViewFilterOperand.IsInFuture,
ViewFilterOperand.IsToday,
].includes(rawUIFilter.operand);
if (!correspondingField) {
continue;
}
if (!isEmptyOperand) {
if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') {
continue;
}
}
switch (rawUIFilter.definition.type) {
case 'TEXT':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
[correspondingField.name]: {
ilike: `%${rawUIFilter.value}%`,
} as StringFilter,
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
not: {
[correspondingField.name]: {
ilike: `%${rawUIFilter.value}%`,
} as StringFilter,
},
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'RAW_JSON':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
[correspondingField.name]: {
like: `%${rawUIFilter.value}%`,
} as RawJsonFilter,
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
not: {
[correspondingField.name]: {
like: `%${rawUIFilter.value}%`,
} as RawJsonFilter,
},
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'DATE':
case 'DATE_TIME': {
const resolvedFilterValue = resolveFilterValue(rawUIFilter);
const now = roundToNearestMinutes(new Date());
const date =
resolvedFilterValue instanceof Date ? resolvedFilterValue : now;
switch (rawUIFilter.operand) {
case ViewFilterOperand.IsAfter: {
objectRecordFilters.push({
[correspondingField.name]: {
gt: date.toISOString(),
} as DateFilter,
});
break;
}
case ViewFilterOperand.IsBefore: {
objectRecordFilters.push({
[correspondingField.name]: {
lt: date.toISOString(),
} as DateFilter,
});
break;
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty: {
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
}
case ViewFilterOperand.IsRelative: {
const dateRange = z
.object({ start: z.date(), end: z.date() })
.safeParse(resolvedFilterValue).data;
const defaultDateRange = resolveFilterValue({
value: 'PAST_1_DAY',
definition: {
type: 'DATE',
},
operand: ViewFilterOperand.IsRelative,
});
if (!defaultDateRange) {
throw new Error('Failed to resolve default date range');
}
const { start, end } = dateRange ?? defaultDateRange;
objectRecordFilters.push({
and: [
{
[correspondingField.name]: {
gte: start.toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
lte: end.toISOString(),
} as DateFilter,
},
],
});
break;
}
case ViewFilterOperand.Is: {
const isValid = resolvedFilterValue instanceof Date;
const date = isValid ? resolvedFilterValue : now;
objectRecordFilters.push({
and: [
{
[correspondingField.name]: {
lte: endOfDay(date).toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
gte: startOfDay(date).toISOString(),
} as DateFilter,
},
],
});
break;
}
case ViewFilterOperand.IsInPast:
objectRecordFilters.push({
[correspondingField.name]: {
lte: now.toISOString(),
} as DateFilter,
});
break;
case ViewFilterOperand.IsInFuture:
objectRecordFilters.push({
[correspondingField.name]: {
gte: now.toISOString(),
} as DateFilter,
});
break;
case ViewFilterOperand.IsToday: {
objectRecordFilters.push({
and: [
{
[correspondingField.name]: {
lte: endOfDay(now).toISOString(),
} as DateFilter,
},
{
[correspondingField.name]: {
gte: startOfDay(now).toISOString(),
} as DateFilter,
},
],
});
break;
}
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, //
);
}
break;
}
case 'RATING':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
[correspondingField.name]: {
eq: convertRatingToRatingValue(parseFloat(rawUIFilter.value)),
} as StringFilter,
});
break;
case ViewFilterOperand.GreaterThan:
objectRecordFilters.push({
[correspondingField.name]: {
in: convertGreaterThanRatingToArrayOfRatingValues(
parseFloat(rawUIFilter.value),
),
} as StringFilter,
});
break;
case ViewFilterOperand.LessThan:
objectRecordFilters.push({
[correspondingField.name]: {
in: convertLessThanRatingToArrayOfRatingValues(
parseFloat(rawUIFilter.value),
),
} as StringFilter,
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'NUMBER':
switch (rawUIFilter.operand) {
case ViewFilterOperand.GreaterThan:
objectRecordFilters.push({
[correspondingField.name]: {
gte: parseFloat(rawUIFilter.value),
} as FloatFilter,
});
break;
case ViewFilterOperand.LessThan:
objectRecordFilters.push({
[correspondingField.name]: {
lte: parseFloat(rawUIFilter.value),
} as FloatFilter,
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'RELATION': {
if (!isEmptyOperand) {
try {
JSON.parse(rawUIFilter.value);
} catch (e) {
throw new Error(
`Cannot parse filter value for RELATION filter : "${rawUIFilter.value}"`,
);
}
const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[];
if (parsedRecordIds.length > 0) {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
});
break;
case ViewFilterOperand.IsNot:
if (parsedRecordIds.length > 0) {
objectRecordFilters.push({
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
},
});
}
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
}
} else {
switch (rawUIFilter.operand) {
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown empty operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
}
break;
}
case 'CURRENCY':
switch (rawUIFilter.operand) {
case ViewFilterOperand.GreaterThan:
objectRecordFilters.push({
[correspondingField.name]: {
amountMicros: { gte: parseFloat(rawUIFilter.value) * 1000000 },
} as CurrencyFilter,
});
break;
case ViewFilterOperand.LessThan:
objectRecordFilters.push({
[correspondingField.name]: {
amountMicros: { lte: parseFloat(rawUIFilter.value) * 1000000 },
} as CurrencyFilter,
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'LINKS': {
const linksFilters = generateILikeFiltersForCompositeFields(
rawUIFilter.value,
correspondingField.name,
['primaryLinkLabel', 'primaryLinkUrl'],
);
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
if (!isCompositeFieldFiter) {
objectRecordFilters.push({
or: linksFilters,
});
} else {
objectRecordFilters.push({
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${rawUIFilter.value}%`,
},
},
});
}
break;
case ViewFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) {
objectRecordFilters.push({
and: linksFilters.map((filter) => {
return {
not: filter,
};
}),
});
} else {
objectRecordFilters.push({
not: {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${rawUIFilter.value}%`,
},
},
},
});
}
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
}
case 'FULL_NAME': {
const fullNameFilters = generateILikeFiltersForCompositeFields(
rawUIFilter.value,
correspondingField.name,
['firstName', 'lastName'],
);
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
if (!isCompositeFieldFiter) {
objectRecordFilters.push({
or: fullNameFilters,
});
} else {
objectRecordFilters.push({
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${rawUIFilter.value}%`,
},
},
});
}
break;
case ViewFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) {
objectRecordFilters.push({
and: fullNameFilters.map((filter) => {
return {
not: filter,
};
}),
});
} else {
objectRecordFilters.push({
not: {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${rawUIFilter.value}%`,
},
},
},
});
}
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
}
case 'ADDRESS':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
if (!isCompositeFieldFiter) {
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
addressStreet1: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressStreet2: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCity: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressState: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCountry: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressPostcode: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
],
});
} else {
objectRecordFilters.push({
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${rawUIFilter.value}%`,
} as AddressFilter,
},
});
}
break;
case ViewFilterOperand.DoesNotContain:
if (!isCompositeFieldFiter) {
objectRecordFilters.push({
and: [
{
not: {
[correspondingField.name]: {
addressStreet1: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
},
{
not: {
[correspondingField.name]: {
addressStreet2: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
},
{
not: {
[correspondingField.name]: {
addressCity: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
},
],
});
} else {
objectRecordFilters.push({
not: {
[correspondingField.name]: {
[compositeFieldName]: {
ilike: `%${rawUIFilter.value}%`,
} as AddressFilter,
},
},
});
}
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'SELECT': {
if (isEmptyOperand) {
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
}
const stringifiedSelectValues = rawUIFilter.value;
let parsedOptionValues: string[] = [];
if (!isNonEmptyString(stringifiedSelectValues)) {
break;
}
try {
parsedOptionValues = JSON.parse(stringifiedSelectValues);
} catch (e) {
throw new Error(
`Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`,
);
}
if (parsedOptionValues.length > 0) {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
[correspondingField.name]: {
in: parsedOptionValues,
} as UUIDFilter,
});
break;
case ViewFilterOperand.IsNot:
objectRecordFilters.push({
not: {
[correspondingField.name]: {
in: parsedOptionValues,
} as UUIDFilter,
},
});
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
}
break;
}
// TODO: fix this with a new composite field in ViewFilter entity
case 'ACTOR': {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is: {
const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[];
objectRecordFilters.push({
[correspondingField.name]: {
source: {
in: parsedRecordIds,
} as RelationFilter,
},
});
break;
}
case ViewFilterOperand.IsNot: {
const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[];
if (parsedRecordIds.length > 0) {
objectRecordFilters.push({
not: {
[correspondingField.name]: {
source: {
in: parsedRecordIds,
} as RelationFilter,
},
},
});
}
break;
}
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
name: {
ilike: `%${rawUIFilter.value}%`,
},
} as ActorFilter,
},
],
});
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,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.label} filter`,
);
}
break;
}
case 'EMAILS':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
primaryEmail: {
ilike: `%${rawUIFilter.value}%`,
},
} as EmailsFilter,
},
],
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: [
{
not: {
[correspondingField.name]: {
primaryEmail: {
ilike: `%${rawUIFilter.value}%`,
},
} as EmailsFilter,
},
},
],
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
case 'PHONES': {
const phonesFilters = generateILikeFiltersForCompositeFields(
rawUIFilter.value,
correspondingField.name,
['primaryPhoneNumber', 'primaryPhoneCountryCode'],
);
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: phonesFilters,
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: phonesFilters.map((filter) => {
return {
not: filter,
};
}),
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
}
case 'ARRAY': {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains: {
objectRecordFilters.push({
[correspondingField.name]: {
contains: [`${rawUIFilter.value}`],
} as ArrayFilter,
});
break;
}
case ViewFilterOperand.DoesNotContain: {
objectRecordFilters.push({
[correspondingField.name]: {
not_contains: [`${rawUIFilter.value}`],
} as ArrayFilter,
});
break;
}
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.label} filter`,
);
}
break;
}
default:
throw new Error('Unknown filter type');
}
}
return makeAndFilterVariables(objectRecordFilters);
};

View File

@ -26,6 +26,7 @@ import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActio
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewBar } from '@/views/components/ViewBar';
@ -72,6 +73,9 @@ export const RecordIndexContainer = () => {
const { columnDefinitions, filterDefinitions, sortDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const setRecordIndexViewFilterGroups = useSetRecoilState(
recordIndexViewFilterGroupsState,
);
const setRecordIndexFilters = useSetRecoilState(recordIndexFiltersState);
const setRecordIndexSorts = useSetRecoilState(recordIndexSortsState);
const setRecordIndexIsCompactModeActive = useSetRecoilState(
@ -81,7 +85,12 @@ export const RecordIndexContainer = () => {
recordIndexKanbanFieldMetadataIdState,
);
const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({
const {
setTableViewFilterGroups,
setTableFilters,
setTableSorts,
setTableColumns,
} = useRecordTable({
recordTableId: recordIndexId,
});
@ -164,12 +173,14 @@ export const RecordIndexContainer = () => {
onViewFieldsChange(view.viewFields);
onViewGroupsChange(view.viewGroups);
setTableViewFilterGroups(view.viewFilterGroups ?? []);
setTableFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setRecordIndexFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setRecordIndexViewFilterGroups(view.viewFilterGroups ?? []);
setContextStoreTargetedRecordsRule((prev) => ({
...prev,
filters: mapViewFiltersToFilters(

View File

@ -5,13 +5,14 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState';
import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView';
@ -45,18 +46,24 @@ export const useLoadRecordIndexBoard = ({
setFieldDefinitions(recordIndexFieldDefinitions);
}, [recordIndexFieldDefinitions, setFieldDefinitions]);
const recordIndexViewFilterGroups = useRecoilValue(
recordIndexViewFilterGroupsState,
);
const recordIndexGroupDefinitions = useRecoilComponentValueV2(
recordGroupDefinitionsComponentState,
);
useEffect(() => {
setColumns(recordIndexGroupDefinitions);
}, [recordIndexGroupDefinitions, setColumns]);
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
const recordIndexSorts = useRecoilValue(recordIndexSortsState);
const requestFilters = turnFiltersIntoQueryFilter(
const requestFilters = computeViewRecordGqlOperationFilter(
recordIndexFilters,
objectMetadataItem?.fields ?? [],
recordIndexViewFilterGroups,
);
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, recordIndexSorts);

View File

@ -4,14 +4,15 @@ import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { isDefined } from '~/utils/isDefined';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
type UseLoadRecordIndexBoardProps = {
objectNameSingular: string;
@ -33,12 +34,17 @@ export const useLoadRecordIndexBoardColumn = ({
const { columnsFamilySelector } = useRecordBoardStates(recordBoardId);
const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore();
const recordIndexViewFilterGroups = useRecoilValue(
recordIndexViewFilterGroupsState,
);
const recordIndexFilters = useRecoilValue(recordIndexFiltersState);
const recordIndexSorts = useRecoilValue(recordIndexSortsState);
const columnDefinition = useRecoilValue(columnsFamilySelector(columnId));
const requestFilters = turnFiltersIntoQueryFilter(
const requestFilters = computeViewRecordGqlOperationFilter(
recordIndexFilters,
objectMetadataItem?.fields ?? [],
recordIndexViewFilterGroups,
);
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, recordIndexSorts);

View File

@ -5,7 +5,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { useRecordTableRecordGqlFields } from '@/object-record/record-index/hooks/useRecordTableRecordGqlFields';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
@ -21,15 +21,17 @@ export const useFindManyParams = (
objectNameSingular,
});
const { tableFiltersState, tableSortsState } =
const { tableFiltersState, tableSortsState, tableViewFilterGroupsState } =
useRecordTableStates(recordTableId);
const tableViewFilterGroups = useRecoilValue(tableViewFilterGroupsState);
const tableFilters = useRecoilValue(tableFiltersState);
const tableSorts = useRecoilValue(tableSortsState);
const filter = turnFiltersIntoQueryFilter(
const filter = computeViewRecordGqlOperationFilter(
tableFilters,
objectMetadataItem?.fields ?? [],
tableViewFilterGroups,
);
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, tableSorts);

View File

@ -0,0 +1,7 @@
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { createState } from 'twenty-ui';
export const recordIndexViewFilterGroupsState = createState<ViewFilterGroup[]>({
key: 'recordIndexViewFilterGroupsState',
defaultValue: [],
});

View File

@ -27,6 +27,7 @@ import { tableFiltersComponentState } from '@/object-record/record-table/states/
import { tableLastRowVisibleComponentState } from '@/object-record/record-table/states/tableLastRowVisibleComponentState';
import { tableRowIdsComponentState } from '@/object-record/record-table/states/tableRowIdsComponentState';
import { tableSortsComponentState } from '@/object-record/record-table/states/tableSortsComponentState';
import { tableViewFilterGroupsComponentState } from '@/object-record/record-table/states/tableViewFilterGroupsComponentState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
@ -45,6 +46,10 @@ export const useRecordTableStates = (recordTableId?: string) => {
availableTableColumnsComponentState,
scopeId,
),
tableViewFilterGroupsState: extractComponentState(
tableViewFilterGroupsComponentState,
scopeId,
),
tableFiltersState: extractComponentState(
tableFiltersComponentState,
scopeId,

View File

@ -34,6 +34,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
const {
scopeId,
availableTableColumnsState,
tableViewFilterGroupsState,
tableFiltersState,
tableSortsState,
tableColumnsState,
@ -67,6 +68,10 @@ export const useRecordTable = (props?: useRecordTableProps) => {
const setOnEntityCountChange = useSetRecoilState(onEntityCountChangeState);
const setTableViewFilterGroups = useSetRecoilState(
tableViewFilterGroupsState,
);
const setTableFilters = useSetRecoilState(tableFiltersState);
const setTableSorts = useSetRecoilState(tableSortsState);
@ -203,6 +208,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
scopeId,
onColumnsChange,
setAvailableTableColumns,
setTableViewFilterGroups,
setTableFilters,
setTableSorts,
setOnEntityCountChange,

View File

@ -0,0 +1,9 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
export const tableViewFilterGroupsComponentState = createComponentState<
ViewFilterGroup[]
>({
key: 'tableViewFilterGroupsComponentState',
defaultValue: [],
});