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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 3349 additions and 1079 deletions

View File

@ -25,9 +25,9 @@ const jestConfig: JestConfigWithTsJest = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 59,
statements: 58,
lines: 55,
functions: 48,
functions: 47,
},
},
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],

View File

@ -1,7 +1,7 @@
import { ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { turnFiltersIntoQueryFilter } from '@/object-record/record-filter/utils/turnFiltersIntoQueryFilter';
import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
export const computeContextStoreFilters = (
@ -12,9 +12,10 @@ export const computeContextStoreFilters = (
if (contextStoreTargetedRecordsRule.mode === 'exclusion') {
queryFilter = makeAndFilterVariables([
turnFiltersIntoQueryFilter(
computeViewRecordGqlOperationFilter(
contextStoreTargetedRecordsRule.filters,
objectMetadataItem?.fields ?? [],
[],
),
contextStoreTargetedRecordsRule.excludedRecordIds.length > 0
? {

View File

@ -24,6 +24,7 @@ export enum CoreObjectNameSingular {
View = 'view',
ViewField = 'viewField',
ViewFilter = 'viewFilter',
ViewFilterGroup = 'viewFilterGroup',
ViewSort = 'viewSort',
ViewGroup = 'viewGroup',
Webhook = 'webhook',

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: [],
});

View File

@ -18,6 +18,7 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF
icon: true,
key: true,
viewFilters: true,
viewFilterGroups: true,
viewSorts: true,
viewFields: true,
viewGroups: true,

View File

@ -1,7 +1,6 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { MouseEvent, useMemo, useRef, useState } from 'react';
import { IconChevronDown, IconComponent } from 'twenty-ui';
import { IconComponent } from 'twenty-ui';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -10,8 +9,8 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { isDefined } from '~/utils/isDefined';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectOption<Value extends string | number | null> = {
@ -47,22 +46,6 @@ const StyledContainer = styled.div<{ fullWidth?: boolean }>`
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
`;
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.tertiary : theme.font.color.primary};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
@ -71,20 +54,6 @@ const StyledLabel = styled.span`
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
overflow: hidden;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronDown = styled(IconChevronDown)<{
disabled?: boolean;
}>`
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
`;
export const Select = <Value extends string | number | null>({
className,
disabled: disabledFromProps,
@ -103,7 +72,6 @@ export const Select = <Value extends string | number | null>({
}: SelectProps<Value>) => {
const selectContainerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const [searchInputValue, setSearchInputValue] = useState('');
const selectedOption =
@ -126,24 +94,6 @@ export const Select = <Value extends string | number | null>({
const { closeDropdown } = useDropdown(dropdownId);
const selectControl = (
<StyledControlContainer disabled={isDisabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
color={
isDisabled ? theme.font.color.light : theme.font.color.primary
}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
<EllipsisDisplay> {selectedOption?.label} </EllipsisDisplay>
</StyledControlLabel>
<StyledIconChevronDown disabled={isDisabled} size={theme.icon.size.md} />
</StyledControlContainer>
);
return (
<StyledContainer
className={className}
@ -154,13 +104,21 @@ export const Select = <Value extends string | number | null>({
>
{!!label && <StyledLabel>{label}</StyledLabel>}
{isDisabled ? (
selectControl
<SelectControl
selectedOption={selectedOption}
isDisabled={isDisabled}
/>
) : (
<Dropdown
dropdownId={dropdownId}
dropdownMenuWidth={dropdownWidth}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
clickableComponent={
<SelectControl
selectedOption={selectedOption}
isDisabled={isDisabled}
/>
}
disableBlur={disableBlur}
dropdownComponents={
<>

View File

@ -0,0 +1,63 @@
import { SelectOption } from '@/ui/input/components/Select';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from 'twenty-ui';
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.tertiary : theme.font.color.primary};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronDown = styled(IconChevronDown)<{
disabled?: boolean;
}>`
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
`;
type SelectControlProps = {
selectedOption: SelectOption<string | number | null>;
isDisabled?: boolean;
};
export const SelectControl = ({
selectedOption,
isDisabled,
}: SelectControlProps) => {
const theme = useTheme();
return (
<StyledControlContainer disabled={isDisabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
color={
isDisabled ? theme.font.color.light : theme.font.color.primary
}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{selectedOption?.label}
</StyledControlLabel>
<StyledIconChevronDown disabled={isDisabled} size={theme.icon.size.md} />
</StyledControlContainer>
);
};

View File

@ -0,0 +1,27 @@
import { IconFilterCog } from 'twenty-ui';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { plural } from 'pluralize';
type AdvancedFilterChipProps = {
onRemove: () => void;
advancedFilterCount?: number;
};
export const AdvancedFilterChip = ({
onRemove,
advancedFilterCount,
}: AdvancedFilterChipProps) => {
const labelText = 'advanced rule';
const chipLabel = `${advancedFilterCount ?? 0} ${advancedFilterCount === 1 ? labelText : plural(labelText)}`;
return (
<SortOrFilterChip
testId={ADVANCED_FILTER_DROPDOWN_ID}
labelKey={chipLabel}
labelValue=""
Icon={IconFilterCog}
onRemove={onRemove}
/>
);
};

View File

@ -0,0 +1,83 @@
import { useCallback } from 'react';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { AdvancedFilterRootLevelViewFilterGroup } from '@/object-record/advanced-filter/components/AdvancedFilterRootLevelViewFilterGroup';
import { useDeleteCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup';
import { AdvancedFilterChip } from '@/views/components/AdvancedFilterChip';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { isDefined } from 'twenty-ui';
export const AdvancedFilterDropdownButton = () => {
const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters();
const { deleteCombinedViewFilterGroup } = useDeleteCombinedViewFilterGroup();
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const advancedViewFilterIds =
currentViewWithCombinedFiltersAndSorts?.viewFilters
.filter((viewFilter) => isDefined(viewFilter.viewFilterGroupId))
.map((viewFilter) => viewFilter.id);
const handleDropdownClickOutside = useCallback(() => {}, []);
const handleDropdownClose = () => {};
const removeAdvancedFilter = useCallback(async () => {
if (!advancedViewFilterIds) {
throw new Error('No advanced view filters to remove');
}
const viewFilterGroupIds =
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups?.map(
(viewFilter) => viewFilter.id,
) ?? [];
for (const viewFilterGroupId of viewFilterGroupIds) {
await deleteCombinedViewFilterGroup(viewFilterGroupId);
}
for (const viewFilterId of advancedViewFilterIds) {
await deleteCombinedViewFilter(viewFilterId);
}
}, [
advancedViewFilterIds,
deleteCombinedViewFilter,
deleteCombinedViewFilterGroup,
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups,
]);
const outermostViewFilterGroupId =
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.find(
(viewFilterGroup) => !viewFilterGroup.parentViewFilterGroupId,
)?.id;
if (!outermostViewFilterGroupId) {
return null;
}
return (
<Dropdown
dropdownId={ADVANCED_FILTER_DROPDOWN_ID}
clickableComponent={
<AdvancedFilterChip
onRemove={removeAdvancedFilter}
advancedFilterCount={advancedViewFilterIds?.length}
/>
}
dropdownComponents={
<AdvancedFilterRootLevelViewFilterGroup
rootLevelViewFilterGroupId={outermostViewFilterGroupId}
/>
}
dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
dropdownMenuWidth={800}
onClickOutside={handleDropdownClickOutside}
onClose={handleDropdownClose}
/>
);
};

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect } from 'react';
import { MultipleFiltersDropdownContent } from '@/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { FilterOperand } from '@/object-record/object-filter-dropdown/types/FilterOperand';
@ -11,6 +10,7 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { EditableFilterChip } from '@/views/components/EditableFilterChip';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput';
import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { isDefined } from '~/utils/isDefined';
@ -98,7 +98,7 @@ export const EditableFilterDropdownButton = ({
<EditableFilterChip viewFilter={viewFilter} onRemove={handleRemove} />
}
dropdownComponents={
<MultipleFiltersDropdownContent
<ObjectFilterOperandSelectAndInput
filterDropdownId={viewFilterDropdownId}
/>
}

View File

@ -7,6 +7,7 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { AdvancedFilterDropdownButton } from '@/views/components/AdvancedFilterDropdownButton';
import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton';
import { EditableSortChip } from '@/views/components/EditableSortChip';
import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect';
@ -137,11 +138,16 @@ export const ViewBarDetails = ({
const otherViewFilters =
currentViewWithCombinedFiltersAndSorts.viewFilters.filter(
(viewFilter) => viewFilter.variant && viewFilter.variant !== 'default',
(viewFilter) =>
viewFilter.variant &&
viewFilter.variant !== 'default' &&
!viewFilter.viewFilterGroupId,
);
const defaultViewFilters =
currentViewWithCombinedFiltersAndSorts.viewFilters.filter(
(viewFilter) => !viewFilter.variant || viewFilter.variant === 'default',
(viewFilter) =>
(!viewFilter.variant || viewFilter.variant === 'default') &&
!viewFilter.viewFilterGroupId,
);
return {
@ -166,6 +172,10 @@ export const ViewBarDetails = ({
return null;
}
const showAdvancedFilterDropdownButton =
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups &&
currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.length > 0;
return (
<StyledBar>
<StyledFilterContainer>
@ -199,6 +209,7 @@ export const ViewBarDetails = ({
<StyledSeperator />
</StyledSeperatorContainer>
)}
{showAdvancedFilterDropdownButton && <AdvancedFilterDropdownButton />}
{mapViewFiltersToFilters(
defaultViewFilters,
availableFilterDefinitions,

View File

@ -0,0 +1 @@
export const ADVANCED_FILTER_DROPDOWN_ID = 'advanced-filter';

View File

@ -0,0 +1,214 @@
import { useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { isDefined } from 'twenty-ui';
export const usePersistViewFilterGroupRecords = () => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ViewFilterGroup,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.ViewFilterGroup,
});
const { destroyOneRecordMutation } = useDestroyOneRecordMutation({
objectNameSingular: CoreObjectNameSingular.ViewFilterGroup,
});
const { createOneRecordMutation } = useCreateOneRecordMutation({
objectNameSingular: CoreObjectNameSingular.ViewFilterGroup,
});
const { updateOneRecordMutation } = useUpdateOneRecordMutation({
objectNameSingular: CoreObjectNameSingular.ViewFilterGroup,
});
const { objectMetadataItems } = useObjectMetadataItems();
const apolloClient = useApolloClient();
const createViewFilterGroupRecord = useCallback(
async (viewFilterGroup: ViewFilterGroup, view: GraphQLView) => {
const result = await apolloClient.mutate<{
createViewFilterGroup: ViewFilterGroup;
}>({
mutation: createOneRecordMutation,
variables: {
input: {
id: viewFilterGroup.id,
viewId: view.id,
parentViewFilterGroupId: viewFilterGroup.parentViewFilterGroupId,
logicalOperator: viewFilterGroup.logicalOperator,
positionInViewFilterGroup:
viewFilterGroup.positionInViewFilterGroup,
},
},
update: (cache, { data }) => {
const record = data?.createViewFilterGroup;
if (!record) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
});
},
});
if (!result.data) {
throw new Error('Failed to create view filter group');
}
return { newRecordId: result.data.createViewFilterGroup.id };
},
[
apolloClient,
createOneRecordMutation,
objectMetadataItem,
objectMetadataItems,
],
);
const createViewFilterGroupRecords = useCallback(
async (viewFilterGroupsToCreate: ViewFilterGroup[], view: GraphQLView) => {
if (!viewFilterGroupsToCreate.length) return [];
const oldToNewId = new Map<string, string>();
for (const viewFilterGroupToCreate of viewFilterGroupsToCreate) {
const newParentViewFilterGroupId = isDefined(
viewFilterGroupToCreate.parentViewFilterGroupId,
)
? (oldToNewId.get(viewFilterGroupToCreate.parentViewFilterGroupId) ??
viewFilterGroupToCreate.parentViewFilterGroupId)
: undefined;
const { newRecordId } = await createViewFilterGroupRecord(
{
...viewFilterGroupToCreate,
parentViewFilterGroupId: newParentViewFilterGroupId,
},
view,
);
oldToNewId.set(viewFilterGroupToCreate.id, newRecordId);
}
const newRecordIds = viewFilterGroupsToCreate.map((viewFilterGroup) => {
const newId = oldToNewId.get(viewFilterGroup.id);
if (!newId) {
throw new Error('Failed to create view filter group');
}
return newId;
});
return newRecordIds;
},
[createViewFilterGroupRecord],
);
const updateViewFilterGroupRecords = useCallback(
(viewFilterGroupsToUpdate: ViewFilterGroup[]) => {
if (!viewFilterGroupsToUpdate.length) return;
return Promise.all(
viewFilterGroupsToUpdate.map((viewFilterGroup) =>
apolloClient.mutate<{ updateViewFilterGroup: ViewFilterGroup }>({
mutation: updateOneRecordMutation,
variables: {
idToUpdate: viewFilterGroup.id,
input: {
parentViewFilterGroupId:
viewFilterGroup.parentViewFilterGroupId,
logicalOperator: viewFilterGroup.logicalOperator,
positionInViewFilterGroup:
viewFilterGroup.positionInViewFilterGroup,
},
},
update: (cache, { data }) => {
const record = data?.updateViewFilterGroup;
if (!record) return;
const cachedRecord = getRecordFromCache<ObjectRecord>(record.id);
if (!cachedRecord) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: cachedRecord,
updatedRecord: record,
objectMetadataItems,
});
},
}),
),
);
},
[
apolloClient,
getRecordFromCache,
objectMetadataItem,
objectMetadataItems,
updateOneRecordMutation,
],
);
const deleteViewFilterGroupRecords = useCallback(
(viewFilterGroupIdsToDelete: string[]) => {
if (!viewFilterGroupIdsToDelete.length) return;
return Promise.all(
viewFilterGroupIdsToDelete.map((viewFilterGroupId) =>
apolloClient.mutate<{ destroyViewFilterGroup: ViewFilterGroup }>({
mutation: destroyOneRecordMutation,
variables: {
idToDestroy: viewFilterGroupId,
},
update: (cache, { data }) => {
const record = data?.destroyViewFilterGroup;
if (!record) return;
const cachedRecord = getRecordFromCache(record.id, cache);
if (!cachedRecord) return;
triggerDestroyRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToDestroy: [cachedRecord],
objectMetadataItems,
});
},
}),
),
);
},
[
apolloClient,
destroyOneRecordMutation,
getRecordFromCache,
objectMetadataItem,
objectMetadataItems,
],
);
return {
createViewFilterGroupRecords,
updateViewFilterGroupRecords,
deleteViewFilterGroupRecords,
};
};

View File

@ -50,11 +50,13 @@ export const usePersistViewFilterRecords = () => {
mutation: createOneRecordMutation,
variables: {
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: view.id,
value: viewFilter.value,
displayValue: viewFilter.displayValue,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
},
},
update: (cache, { data }) => {

View File

@ -4,9 +4,11 @@ import { RecordIndexRootPropsContext } from '@/object-record/record-index/contex
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords';
import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroupRecords';
import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords';
import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useGetViewFilterGroupsCombined } from '@/views/hooks/useGetCombinedViewFilterGroups';
import { useGetViewFiltersCombined } from '@/views/hooks/useGetCombinedViewFilters';
import { useGetViewSortsCombined } from '@/views/hooks/useGetCombinedViewSorts';
import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache';
@ -45,6 +47,8 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
const { getViewSortsCombined } = useGetViewSortsCombined(viewBarComponentId);
const { getViewFiltersCombined } =
useGetViewFiltersCombined(viewBarComponentId);
const { getViewFilterGroupsCombined } =
useGetViewFilterGroupsCombined(viewBarComponentId);
const { createViewSortRecords } = usePersistViewSortRecords();
@ -52,6 +56,8 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
const { createViewFilterRecords } = usePersistViewFilterRecords();
const { createViewFilterGroupRecords } = usePersistViewFilterGroupRecords();
const { objectMetadataItem } = useContext(RecordIndexRootPropsContext);
const createViewFromCurrentView = useRecoilCallback(
@ -143,11 +149,18 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
}
if (shouldCopyFiltersAndSorts === true) {
const sourceViewCombinedFilterGroups = getViewFilterGroupsCombined(
view.id,
);
const sourceViewCombinedFilters = getViewFiltersCombined(view.id);
const sourceViewCombinedSorts = getViewSortsCombined(view.id);
await createViewSortRecords(sourceViewCombinedSorts, view);
await createViewFilterRecords(sourceViewCombinedFilters, view);
await createViewFilterGroupRecords(
sourceViewCombinedFilterGroups,
view,
);
}
set(isPersistingViewFieldsCallbackState, false);
@ -160,10 +173,12 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
createViewFieldRecords,
getViewSortsCombined,
getViewFiltersCombined,
getViewFilterGroupsCombined,
currentViewIdCallbackState,
getViewFromCache,
isPersistingViewFieldsCallbackState,
createViewGroupRecords,
createViewFilterGroupRecords,
],
);

View File

@ -0,0 +1,67 @@
import { useRecoilCallback } from 'recoil';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { View } from '@/views/types/View';
import { getCombinedViewFilterGroups } from '@/views/utils/getCombinedViewFilterGroups';
import { isDefined } from '~/utils/isDefined';
export const useGetViewFilterGroupsCombined = (viewBarComponentId?: string) => {
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const unsavedToUpsertViewFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
viewBarComponentId,
);
const unsavedToDeleteViewFilterGroupIdsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToDeleteViewFilterGroupIdsComponentFamilyState,
viewBarComponentId,
);
const getViewFilterGroupsCombined = useRecoilCallback(
({ snapshot }) =>
(viewId: string) => {
const view = views.find((view) => view.id === viewId);
if (!isDefined(view)) {
throw new Error(
`Cannot get view with id ${viewId}, because it cannot be found in client cache data.`,
);
}
const unsavedToUpsertViewFilterGroups = getSnapshotValue(
snapshot,
unsavedToUpsertViewFilterGroupsCallbackState({ viewId: view.id }),
);
const unsavedToDeleteViewFilterGroupIds = getSnapshotValue(
snapshot,
unsavedToDeleteViewFilterGroupIdsCallbackState({ viewId: view.id }),
);
const combinedViewFilterGroups = getCombinedViewFilterGroups(
view.viewFilterGroups ?? [],
unsavedToUpsertViewFilterGroups,
unsavedToDeleteViewFilterGroupIds,
);
return combinedViewFilterGroups;
},
[
views,
unsavedToDeleteViewFilterGroupIdsCallbackState,
unsavedToUpsertViewFilterGroupsCallbackState,
],
);
return {
getViewFilterGroupsCombined,
};
};

View File

@ -9,12 +9,15 @@ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-sta
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { isCurrentViewKeyIndexComponentState } from '@/views/states/isCurrentViewIndexComponentState';
import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState';
import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState';
import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState';
import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState';
import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState';
import { View } from '@/views/types/View';
import { getCombinedViewFilterGroups } from '@/views/utils/getCombinedViewFilterGroups';
import { getCombinedViewFilters } from '@/views/utils/getCombinedViewFilters';
import { getCombinedViewSorts } from '@/views/utils/getCombinedViewSorts';
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
@ -70,6 +73,12 @@ export const useGetCurrentView = (viewBarInstanceId?: string) => {
instanceId,
);
const unsavedToUpsertViewFilterGroups = useRecoilComponentFamilyValueV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
{ viewId },
instanceId,
);
const unsavedToUpsertViewSorts = useRecoilComponentFamilyValueV2(
unsavedToUpsertViewSortsComponentFamilyState,
{ viewId },
@ -82,6 +91,12 @@ export const useGetCurrentView = (viewBarInstanceId?: string) => {
instanceId,
);
const unsavedToDeleteViewFilterGroupIds = useRecoilComponentFamilyValueV2(
unsavedToDeleteViewFilterGroupIdsComponentFamilyState,
{ viewId },
instanceId,
);
const unsavedToDeleteViewSortIds = useRecoilComponentFamilyValueV2(
unsavedToDeleteViewSortIdsComponentFamilyState,
{ viewId },
@ -104,6 +119,11 @@ export const useGetCurrentView = (viewBarInstanceId?: string) => {
unsavedToUpsertViewFilters,
unsavedToDeleteViewFilterIds,
),
viewFilterGroups: getCombinedViewFilterGroups(
currentView.viewFilterGroups ?? [],
unsavedToUpsertViewFilterGroups,
unsavedToDeleteViewFilterGroupIds,
),
viewSorts: getCombinedViewSorts(
currentView.viewSorts,
unsavedToUpsertViewSorts,

View File

@ -1,6 +1,8 @@
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState';
import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState';
import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState';
import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState';
import { useRecoilCallback } from 'recoil';
@ -18,6 +20,12 @@ export const useResetUnsavedViewStates = (viewBarInstanceId?: string) => {
viewBarInstanceId,
);
const unsavedToDeleteViewFilterGroupIdsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToDeleteViewFilterGroupIdsComponentFamilyState,
viewBarInstanceId,
);
const setUnsavedToUpsertViewFiltersCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFiltersComponentFamilyState,
@ -30,19 +38,29 @@ export const useResetUnsavedViewStates = (viewBarInstanceId?: string) => {
viewBarInstanceId,
);
const unsavedToUpsertViewFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
viewBarInstanceId,
);
const resetUnsavedViewStates = useRecoilCallback(
({ set }) =>
(viewId: string) => {
set(unsavedToDeleteViewFilterGroupIdsCallbackState({ viewId }), []);
set(setUnsavedToDeleteViewFilterIdsCallbackState({ viewId }), []);
set(setUnsavedToDeleteViewSortIdsCallbackState({ viewId }), []);
set(unsavedToUpsertViewFilterGroupsCallbackState({ viewId }), []);
set(setUnsavedToUpsertViewFiltersCallbackState({ viewId }), []);
set(unsavedToUpsertViewSortsCallbackState({ viewId }), []);
},
[
unsavedToUpsertViewSortsCallbackState,
setUnsavedToUpsertViewFiltersCallbackState,
unsavedToUpsertViewFilterGroupsCallbackState,
setUnsavedToDeleteViewSortIdsCallbackState,
setUnsavedToDeleteViewFilterIdsCallbackState,
unsavedToDeleteViewFilterGroupIdsCallbackState,
],
);

View File

@ -2,13 +2,16 @@ import { useRecoilCallback } from 'recoil';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroupRecords';
import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache';
import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState';
import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState';
import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState';
import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState';
import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState';
import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState';
import { isDefined } from '~/utils/isDefined';
@ -48,6 +51,18 @@ export const useSaveCurrentViewFiltersAndSorts = (
viewBarComponentId,
);
const unsavedToUpsertViewFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToUpsertViewFilterGroupsComponentFamilyState,
viewBarComponentId,
);
const unsavedToDeleteViewFilterGroupIdsCallbackState =
useRecoilComponentCallbackStateV2(
unsavedToDeleteViewFilterGroupIdsComponentFamilyState,
viewBarComponentId,
);
const {
createViewSortRecords,
updateViewSortRecords,
@ -60,6 +75,12 @@ export const useSaveCurrentViewFiltersAndSorts = (
deleteViewFilterRecords,
} = usePersistViewFilterRecords();
const {
createViewFilterGroupRecords,
deleteViewFilterGroupRecords,
updateViewFilterGroupRecords,
} = usePersistViewFilterGroupRecords();
const { resetUnsavedViewStates } =
useResetUnsavedViewStates(viewBarComponentId);
@ -131,14 +152,14 @@ export const useSaveCurrentViewFiltersAndSorts = (
const viewFiltersToCreate = unsavedToUpsertViewFilters.filter(
(viewFilter) =>
!view.viewFilters.some(
(vf) => vf.fieldMetadataId === viewFilter.fieldMetadataId,
(viewFilterToFilter) => viewFilterToFilter.id === viewFilter.id,
),
);
const viewFiltersToUpdate = unsavedToUpsertViewFilters.filter(
(viewFilter) =>
view.viewFilters.some(
(vf) => vf.fieldMetadataId === viewFilter.fieldMetadataId,
(viewFilterToFilter) => viewFilterToFilter.id === viewFilter.id,
),
);
@ -156,6 +177,55 @@ export const useSaveCurrentViewFiltersAndSorts = (
],
);
const saveViewFilterGroups = useRecoilCallback(
({ snapshot }) =>
async (viewId: string) => {
const unsavedToDeleteViewFilterGroupIds = getSnapshotValue(
snapshot,
unsavedToDeleteViewFilterGroupIdsCallbackState({ viewId }),
);
const unsavedToUpsertViewFilterGroups = getSnapshotValue(
snapshot,
unsavedToUpsertViewFilterGroupsCallbackState({ viewId }),
);
const view = await getViewFromCache(viewId);
if (isUndefinedOrNull(view)) {
return;
}
const viewFilterGroupsToCreate = unsavedToUpsertViewFilterGroups.filter(
(viewFilterGroup) =>
!view.viewFilterGroups?.some(
(viewFilterGroupToFilter) =>
viewFilterGroupToFilter.id === viewFilterGroup.id,
),
);
const viewFilterGroupsToUpdate = unsavedToUpsertViewFilterGroups.filter(
(viewFilterGroup) =>
view.viewFilterGroups?.some(
(viewFilterGroupToFilter) =>
viewFilterGroupToFilter.id === viewFilterGroup.id,
),
);
await createViewFilterGroupRecords(viewFilterGroupsToCreate, view);
await updateViewFilterGroupRecords(viewFilterGroupsToUpdate);
await deleteViewFilterGroupRecords(unsavedToDeleteViewFilterGroupIds);
},
[
getViewFromCache,
createViewFilterGroupRecords,
deleteViewFilterGroupRecords,
unsavedToDeleteViewFilterGroupIdsCallbackState,
unsavedToUpsertViewFilterGroupsCallbackState,
updateViewFilterGroupRecords,
],
);
const saveCurrentViewFilterAndSorts = useRecoilCallback(
({ snapshot }) =>
async (viewIdFromProps?: string) => {
@ -169,6 +239,7 @@ export const useSaveCurrentViewFiltersAndSorts = (
const viewId = viewIdFromProps ?? currentViewId;
await saveViewFilterGroups(viewId);
await saveViewFilters(viewId);
await saveViewSorts(viewId);
@ -179,6 +250,7 @@ export const useSaveCurrentViewFiltersAndSorts = (
resetUnsavedViewStates,
saveViewFilters,
saveViewSorts,
saveViewFilterGroups,
],
);

View File

@ -8,6 +8,7 @@ import { currentViewIdComponentState } from '@/views/states/currentViewIdCompone
import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState';
import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState';
import { ViewFilter } from '@/views/types/ViewFilter';
import { shouldReplaceFilter } from '@/views/utils/shouldReplaceFilter';
import { isDefined } from '~/utils/isDefined';
export const useUpsertCombinedViewFilters = (viewBarComponentId?: string) => {
@ -59,19 +60,16 @@ export const useUpsertCombinedViewFilters = (viewBarComponentId?: string) => {
}
const matchingFilterInCurrentView = currentView.viewFilters.find(
(viewFilter) =>
viewFilter.fieldMetadataId === upsertedFilter.fieldMetadataId,
(viewFilter) => shouldReplaceFilter(viewFilter, upsertedFilter),
);
const matchingFilterInUnsavedFilters = unsavedToUpsertViewFilters.find(
(viewFilter) =>
viewFilter.fieldMetadataId === upsertedFilter.fieldMetadataId,
(viewFilter) => shouldReplaceFilter(viewFilter, upsertedFilter),
);
if (isDefined(matchingFilterInUnsavedFilters)) {
const updatedFilters = unsavedToUpsertViewFilters.map((viewFilter) =>
viewFilter.fieldMetadataId ===
matchingFilterInUnsavedFilters.fieldMetadataId
shouldReplaceFilter(viewFilter, matchingFilterInUnsavedFilters)
? { ...viewFilter, ...upsertedFilter, id: viewFilter.id }
: viewFilter,
);

View File

@ -0,0 +1,9 @@
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const unsavedToDeleteViewFilterGroupIdsComponentFamilyState =
createComponentFamilyStateV2<string[], { viewId?: string }>({
key: 'unsavedToDeleteViewFilterGroupIdsComponentFamilyState',
defaultValue: [],
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
export const unsavedToUpsertViewFilterGroupsComponentFamilyState =
createComponentFamilyStateV2<ViewFilterGroup[], { viewId?: string }>({
key: 'unsavedToUpsertViewFilterGroupsComponentFamilyState',
defaultValue: [],
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@ -1,6 +1,6 @@
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { ViewFilter } from '../types/ViewFilter';
import { ViewFilter } from '@/views/types/ViewFilter';
export const unsavedToUpsertViewFiltersComponentFamilyState =
createComponentFamilyStateV2<ViewFilter[], { viewId?: string }>({

View File

@ -1,5 +1,6 @@
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewSort } from '@/views/types/ViewSort';
@ -15,6 +16,7 @@ export type GraphQLView = {
isCompact: boolean;
viewFields: ViewField[];
viewFilters: ViewFilter[];
viewFilterGroups?: ViewFilterGroup[];
viewSorts: ViewSort[];
viewGroups: ViewGroup[];
position: number;

View File

@ -1,5 +1,6 @@
import { ViewField } from '@/views/types/ViewField';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewSort } from '@/views/types/ViewSort';
@ -15,6 +16,7 @@ export type View = {
viewFields: ViewField[];
viewGroups: ViewGroup[];
viewFilters: ViewFilter[];
viewFilterGroups?: ViewFilterGroup[];
viewSorts: ViewSort[];
kanbanFieldMetadataId: string;
position: number;

View File

@ -12,5 +12,7 @@ export type ViewFilter = {
createdAt?: string;
updatedAt?: string;
viewId?: string;
viewFilterGroupId?: string;
positionInViewFilterGroup?: number | null;
definition?: FilterDefinition;
};

View File

@ -0,0 +1,10 @@
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
export type ViewFilterGroup = {
__typename: 'ViewFilterGroup';
id: string;
viewId: string;
parentViewFilterGroupId?: string | null;
logicalOperator: ViewFilterGroupLogicalOperator;
positionInViewFilterGroup?: number | null;
};

View File

@ -0,0 +1,4 @@
export enum ViewFilterGroupLogicalOperator {
AND = 'AND',
OR = 'OR',
}

View File

@ -0,0 +1,38 @@
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
export const getCombinedViewFilterGroups = (
viewFilterGroups: ViewFilterGroup[],
unsavedToUpsertViewFilterGroups: ViewFilterGroup[],
unsavedToDeleteViewFilterGroupIds: string[],
): ViewFilterGroup[] => {
const toCreateViewFilterGroups = unsavedToUpsertViewFilterGroups.filter(
(toUpsertViewFilterGroup) =>
!viewFilterGroups.some(
(viewFilterGroup) => viewFilterGroup.id === toUpsertViewFilterGroup.id,
),
);
const toUpdateViewFilterGroups = unsavedToUpsertViewFilterGroups.filter(
(toUpsertViewFilterGroup) =>
viewFilterGroups.some(
(viewFilterGroup) => viewFilterGroup.id === toUpsertViewFilterGroup.id,
),
);
const combinedViewFilterGroups = viewFilterGroups
.filter(
(viewFilterGroup) =>
!unsavedToDeleteViewFilterGroupIds.includes(viewFilterGroup.id),
)
.map((viewFilterGroup) => {
const toUpdateViewFilterGroup = toUpdateViewFilterGroups.find(
(toUpdateViewFilterGroup) =>
toUpdateViewFilterGroup.id === viewFilterGroup.id,
);
return toUpdateViewFilterGroup ?? viewFilterGroup;
})
.concat(toCreateViewFilterGroups);
return combinedViewFilterGroups;
};

View File

@ -8,24 +8,19 @@ export const getCombinedViewFilters = (
const toCreateViewFilters = toUpsertViewFilters.filter(
(toUpsertViewFilter) =>
!viewFilters.some(
(viewFilter) =>
viewFilter.fieldMetadataId === toUpsertViewFilter.fieldMetadataId,
(viewFilter) => viewFilter.id === toUpsertViewFilter.id,
),
);
const toUpdateViewFilters = toUpsertViewFilters.filter((toUpsertViewFilter) =>
viewFilters.some(
(viewFilter) =>
viewFilter.fieldMetadataId === toUpsertViewFilter.fieldMetadataId,
),
viewFilters.some((viewFilter) => viewFilter.id === toUpsertViewFilter.id),
);
const combinedViewFilters = viewFilters
.filter((viewFilter) => !toDeleteViewFilterIds.includes(viewFilter.id))
.map((viewFilter) => {
const toUpdateViewFilter = toUpdateViewFilters.find(
(toUpdateViewFilter) =>
toUpdateViewFilter.fieldMetadataId === viewFilter.fieldMetadataId,
(toUpdateViewFilter) => toUpdateViewFilter.id === viewFilter.id,
);
return toUpdateViewFilter ?? viewFilter;

View File

@ -3,7 +3,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { formatFieldMetadataItemsAsFilterDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
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 { View } from '@/views/types/View';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
@ -27,7 +27,7 @@ export const getQueryVariablesFromView = ({
};
}
const { viewFilters, viewSorts } = view;
const { viewFilterGroups, viewFilters, viewSorts } = view;
const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({
fields: fieldMetadataItems,
@ -38,9 +38,10 @@ export const getQueryVariablesFromView = ({
fields: fieldMetadataItems,
});
const filter = turnFiltersIntoQueryFilter(
const filter = computeViewRecordGqlOperationFilter(
mapViewFiltersToFilters(viewFilters, filterDefinitions),
objectMetadataItem?.fields ?? [],
viewFilterGroups ?? [],
);
const orderBy = turnSortsIntoOrderBy(

View File

@ -23,6 +23,8 @@ export const mapViewFiltersToFilters = (
value: viewFilter.value,
displayValue: viewFilter.displayValue,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
definition: viewFilter.definition ?? availableFilterDefinition,
};
})

View File

@ -0,0 +1,18 @@
import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { isDefined } from 'twenty-ui';
export const shouldReplaceFilter = (
oldFilter: Pick<Filter, 'id' | 'fieldMetadataId' | 'viewFilterGroupId'>,
newFilter: Pick<Filter, 'id' | 'fieldMetadataId' | 'viewFilterGroupId'>,
) => {
const isNewFilterAdvancedFilter = isDefined(newFilter.viewFilterGroupId);
if (isNewFilterAdvancedFilter) {
return newFilter.id === oldFilter.id;
} else {
return (
newFilter.fieldMetadataId === oldFilter.fieldMetadataId &&
!oldFilter.viewFilterGroupId
);
}
};

View File

@ -0,0 +1,15 @@
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
export const sortViewFilterGroupsOutermostFirst = (
viewFilterGroups: ViewFilterGroup[],
parentViewFilterGroupId?: string,
): ViewFilterGroup[] => {
const childGroups = viewFilterGroups.filter(
(group) => group.parentViewFilterGroupId === parentViewFilterGroupId,
);
return childGroups.flatMap((group) => [
group,
...sortViewFilterGroupsOutermostFirst(viewFilterGroups, group.id),
]);
};

View File

@ -382,6 +382,15 @@ export const VIEW_FILTER_STANDARD_FIELD_IDS = {
value: '20202020-1e55-4a1e-a1d2-fefb86a5fce5',
displayValue: '20202020-1270-4ebf-9018-c0ec10d5038e',
view: '20202020-4f5b-487e-829c-3d881c163611',
viewFilterGroupId: '20202020-2580-420a-8328-cab1635c0296',
positionInViewFilterGroup: '20202020-3bb0-4f66-a537-a46fe0dc468f',
};
export const VIEW_FILTER_GROUP_STANDARD_FIELD_IDS = {
view: '20202020-ff7a-4b54-8be5-aa0249047b74',
parentViewFilterGroupId: '20202020-edbf-4929-8ede-64f48d6bf2a7',
logicalOperator: '20202020-64d9-4bc5-85ba-c250796ce9aa',
positionInViewFilterGroup: '20202020-90d6-4299-ad87-d05ddd3a0a3f',
};
export const VIEW_SORT_STANDARD_FIELD_IDS = {
@ -402,6 +411,7 @@ export const VIEW_STANDARD_FIELD_IDS = {
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',
viewGroups: '20202020-e1a1-419f-ac81-1986a5ea59a8',
viewFilters: '20202020-ff23-4154-b63c-21fb36cd0967',
viewFilterGroups: '20202020-0318-474a-84a1-bac895ceaa5a',
viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043',
favorites: '20202020-c818-4a86-8284-9ec0ef0a59a5',
};

View File

@ -37,6 +37,7 @@ export const STANDARD_OBJECT_IDS = {
viewField: '20202020-4d19-4655-95bf-b2a04cf206d4',
viewGroup: '20202020-725f-47a4-8008-4255f9519f70',
viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8',
viewFilterGroup: '20202020-b920-4b11-92aa-9b07d878e542',
viewSort: '20202020-e46a-47a8-939a-e5d911f83531',
view: '20202020-722e-4739-8e2c-0c372d661f49',
webhook: '20202020-be4d-4e08-811d-0fffcd13ffd4',

View File

@ -27,6 +27,7 @@ import { AuditLogWorkspaceEntity } from 'src/modules/timeline/standard-objects/a
import { BehavioralEventWorkspaceEntity } from 'src/modules/timeline/standard-objects/behavioral-event.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
@ -59,6 +60,7 @@ export const standardObjectMetadataDefinitions = [
ViewFieldWorkspaceEntity,
ViewGroupWorkspaceEntity,
ViewFilterWorkspaceEntity,
ViewFilterGroupWorkspaceEntity,
ViewSortWorkspaceEntity,
ViewWorkspaceEntity,
WebhookWorkspaceEntity,

View File

@ -0,0 +1,95 @@
import { Relation } from 'typeorm';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { VIEW_FILTER_GROUP_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
export enum ViewFilterGroupLogicalOperator {
AND = 'AND',
OR = 'OR',
NOT = 'NOT',
}
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.viewFilterGroup,
namePlural: 'viewFilterGroups',
labelSingular: 'View Filter Group',
labelPlural: 'View Filter Groups',
description: '(System) View Filter Groups',
icon: 'IconFilterBolt',
})
@WorkspaceIsNotAuditLogged()
@WorkspaceIsSystem()
export class ViewFilterGroupWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceRelation({
standardId: VIEW_FILTER_GROUP_STANDARD_FIELD_IDS.view,
type: RelationMetadataType.MANY_TO_ONE,
label: 'View',
description: 'View',
inverseSideTarget: () => ViewWorkspaceEntity,
inverseSideFieldKey: 'viewFilterGroups',
})
view: Relation<ViewWorkspaceEntity>;
@WorkspaceJoinColumn('view')
viewId: string;
@WorkspaceField({
standardId: VIEW_FILTER_GROUP_STANDARD_FIELD_IDS.parentViewFilterGroupId,
type: FieldMetadataType.UUID,
label: 'Parent View Filter Group Id',
description: 'Parent View Filter Group',
})
@WorkspaceIsNullable()
parentViewFilterGroupId: string | null;
@WorkspaceField({
standardId: VIEW_FILTER_GROUP_STANDARD_FIELD_IDS.logicalOperator,
type: FieldMetadataType.SELECT,
label: 'Logical Operator',
description: 'Logical operator for the filter group',
options: [
{
value: ViewFilterGroupLogicalOperator.AND,
label: 'AND',
position: 0,
color: 'blue',
},
{
value: ViewFilterGroupLogicalOperator.OR,
label: 'OR',
position: 1,
color: 'green',
},
{
value: ViewFilterGroupLogicalOperator.NOT,
label: 'NOT',
position: 2,
color: 'red',
},
],
defaultValue: `'${ViewFilterGroupLogicalOperator.NOT}'`,
})
logicalOperator: string;
@WorkspaceField({
standardId: VIEW_FILTER_GROUP_STANDARD_FIELD_IDS.positionInViewFilterGroup,
type: FieldMetadataType.POSITION,
label: 'Position in view filter group',
description: 'Position in the parent view filter group',
icon: 'IconHierarchy2',
})
@WorkspaceIsSystem()
@WorkspaceIsNullable()
positionInViewFilterGroup: number | null;
}

View File

@ -72,4 +72,24 @@ export class ViewFilterWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceJoinColumn('view')
viewId: string | null;
@WorkspaceField({
standardId: VIEW_FILTER_STANDARD_FIELD_IDS.viewFilterGroupId,
type: FieldMetadataType.UUID,
label: 'View Filter Group Id',
description: 'View Filter Group',
})
@WorkspaceIsNullable()
viewFilterGroupId: string | null;
@WorkspaceField({
standardId: VIEW_FILTER_STANDARD_FIELD_IDS.positionInViewFilterGroup,
type: FieldMetadataType.POSITION,
label: 'Position in view filter group',
description: 'Position in the view filter group',
icon: 'IconHierarchy2',
})
@WorkspaceIsSystem()
@WorkspaceIsNullable()
positionInViewFilterGroup: number | null;
}

View File

@ -16,6 +16,7 @@ import { VIEW_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
@ -138,6 +139,18 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
viewFilters: Relation<ViewFilterWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: VIEW_STANDARD_FIELD_IDS.viewFilterGroups,
type: RelationMetadataType.ONE_TO_MANY,
label: 'View Filter Groups',
description: 'View Filter Groups',
icon: 'IconFilterBolt',
inverseSideTarget: () => ViewFilterGroupWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
viewFilterGroups: Relation<ViewFilterGroupWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: VIEW_STANDARD_FIELD_IDS.viewSorts,
type: RelationMetadataType.ONE_TO_MANY,

View File

@ -126,6 +126,7 @@ export {
IconFileZip,
IconFilter,
IconFilterOff,
IconFilterCog,
IconFocusCentered,
IconForbid,
IconFunction,
@ -151,6 +152,7 @@ export {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconLibraryPlus,
IconLink,
IconLinkOff,
IconList,

View File

@ -3,7 +3,6 @@ export * from './components';
export * from './display';
export * from './feedback';
export * from './input';
export * from './feedback';
export * from './layout';
export * from './navigation';
export * from './testing';