Skip to content

Commit

Permalink
Feat: Advanced filter (#7700)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
ad-elias and lucasbordeau authored Oct 24, 2024
1 parent 1dfeba3 commit 315820e
Show file tree
Hide file tree
Showing 99 changed files with 3,349 additions and 1,079 deletions.
4 changes: 2 additions & 2 deletions packages/twenty-front/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = (
Expand All @@ -12,9 +12,10 @@ export const computeContextStoreFilters = (

if (contextStoreTargetedRecordsRule.mode === 'exclusion') {
queryFilter = makeAndFilterVariables([
turnFiltersIntoQueryFilter(
computeViewRecordGqlOperationFilter(
contextStoreTargetedRecordsRule.filters,
objectMetadataItem?.fields ?? [],
[],
),
contextStoreTargetedRecordsRule.excludedRecordIds.length > 0
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum CoreObjectNameSingular {
View = 'view',
ViewField = 'viewField',
ViewFilter = 'viewFilter',
ViewFilterGroup = 'viewFilterGroup',
ViewSort = 'viewSort',
ViewGroup = 'viewGroup',
Webhook = 'webhook',
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
/>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
Original file line number Diff line number Diff line change
@@ -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}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit 315820e

Please sign in to comment.