diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 66acadc80..fd6de3f70 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -9,6 +9,11 @@ export type UUIDFilter = { is?: IsFilter; }; +export type RelationFilter = { + is?: IsFilter; + in?: UUIDFilterValue[]; +}; + export type BooleanFilter = { eq?: boolean; is?: IsFilter; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx index 1bd909b72..cd49fd096 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { MultipleFiltersDropdownFilterOnFilterChangedEffect } from './MultipleFiltersDropdownFilterOnFilterChangedEffect'; import { ObjectFilterDropdownDateInput } from './ObjectFilterDropdownDateInput'; @@ -36,6 +37,11 @@ export const MultipleFiltersDropdownContent = ({ const selectedOperandInDropdown = useRecoilValue( selectedOperandInDropdownState, ); + const isEmptyOperand = + selectedOperandInDropdown && + [ViewFilterOperand.IsEmpty, ViewFilterOperand.IsNotEmpty].includes( + selectedOperandInDropdown, + ); return ( <> @@ -43,6 +49,8 @@ export const MultipleFiltersDropdownContent = ({ ) : isObjectFilterDropdownOperandSelectUnfolded ? ( + ) : isEmptyOperand ? ( + ) : ( selectedOperandInDropdown && ( <> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx index 46945d4d3..5f500b916 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx @@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil'; import { v4 } from 'uuid'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; @@ -34,10 +35,27 @@ export const ObjectFilterDropdownOperandSelect = () => { filterDefinitionUsedInDropdown?.type, ); - const handleOperangeChange = (newOperand: ViewFilterOperand) => { + const handleOperandChange = (newOperand: ViewFilterOperand) => { + const isEmptyOperand = [ + ViewFilterOperand.IsEmpty, + ViewFilterOperand.IsNotEmpty, + ].includes(newOperand); + setSelectedOperandInDropdown(newOperand); setIsObjectFilterDropdownOperandSelectUnfolded(false); + if (isEmptyOperand) { + selectFilter?.({ + id: v4(), + fieldMetadataId: filterDefinitionUsedInDropdown?.fieldMetadataId ?? '', + displayValue: '', + operand: newOperand, + value: '', + definition: filterDefinitionUsedInDropdown as FilterDefinition, + }); + return; + } + if ( isDefined(filterDefinitionUsedInDropdown) && isDefined(selectedFilter) @@ -63,7 +81,7 @@ export const ObjectFilterDropdownOperandSelect = () => { { - handleOperangeChange(filterOperand); + handleOperandChange(filterOperand); }} text={getOperandLabel(filterOperand)} /> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx index 1644d9472..d5eded8bf 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx @@ -4,20 +4,34 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { getOperandsForFilterType } from '../getOperandsForFilterType'; describe('getOperandsForFilterType', () => { + const emptyOperands = [ + ViewFilterOperand.IsEmpty, + ViewFilterOperand.IsNotEmpty, + ]; + + const containsOperands = [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ]; + + const numberOperands = [ + ViewFilterOperand.GreaterThan, + ViewFilterOperand.LessThan, + ]; + + const relationOperand = [ViewFilterOperand.Is, ViewFilterOperand.IsNot]; + const testCases = [ - ['TEXT', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], - ['EMAIL', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], - [ - 'FULL_NAME', - [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain], - ], - ['ADDRESS', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], - ['LINK', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], - ['LINKS', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], - ['CURRENCY', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]], - ['NUMBER', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]], - ['DATE_TIME', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]], - ['RELATION', [ViewFilterOperand.Is, ViewFilterOperand.IsNot]], + ['TEXT', [...containsOperands, ...emptyOperands]], + ['EMAIL', [...containsOperands, ...emptyOperands]], + ['FULL_NAME', [...containsOperands, ...emptyOperands]], + ['ADDRESS', [...containsOperands, ...emptyOperands]], + ['LINK', [...containsOperands, ...emptyOperands]], + ['LINKS', [...containsOperands, ...emptyOperands]], + ['CURRENCY', [...numberOperands, ...emptyOperands]], + ['NUMBER', [...numberOperands, ...emptyOperands]], + ['DATE_TIME', [...numberOperands, ...emptyOperands]], + ['RELATION', [...relationOperand, ...emptyOperands]], [undefined, []], [null, []], ['UNKNOWN_TYPE', []], diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts index a58bc9db5..9c9e297ef 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts @@ -18,6 +18,10 @@ export const getOperandLabel = ( return 'Is not'; case ViewFilterOperand.IsNotNull: return 'Is not null'; + case ViewFilterOperand.IsEmpty: + return 'Is empty'; + case ViewFilterOperand.IsNotEmpty: + return 'Is not empty'; default: return ''; } @@ -35,6 +39,10 @@ export const getOperandLabelShort = ( return ': Not'; case ViewFilterOperand.IsNotNull: return ': NotNull'; + case ViewFilterOperand.IsNotEmpty: + return ': NotEmpty'; + case ViewFilterOperand.IsEmpty: + return ': Empty'; case ViewFilterOperand.GreaterThan: return '\u00A0> '; case ViewFilterOperand.LessThan: diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index 147e6ee9e..7f189009b 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -5,6 +5,13 @@ import { FilterType } from '../types/FilterType'; export const getOperandsForFilterType = ( filterType: FilterType | null | undefined, ): ViewFilterOperand[] => { + const emptyOperands = [ + ViewFilterOperand.IsEmpty, + ViewFilterOperand.IsNotEmpty, + ]; + + const relationOperands = [ViewFilterOperand.Is, ViewFilterOperand.IsNot]; + switch (filterType) { case 'TEXT': case 'EMAIL': @@ -12,17 +19,25 @@ export const getOperandsForFilterType = ( case 'ADDRESS': case 'PHONE': case 'LINK': - return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]; case 'LINKS': - return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]; + return [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ...emptyOperands, + ]; case 'CURRENCY': case 'NUMBER': case 'DATE_TIME': case 'DATE': - return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]; + return [ + ViewFilterOperand.GreaterThan, + ViewFilterOperand.LessThan, + ...emptyOperands, + ]; case 'RELATION': + return [...relationOperands, ...emptyOperands]; case 'SELECT': - return [ViewFilterOperand.Is, ViewFilterOperand.IsNot]; + return [...relationOperands]; default: return []; } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts index 3235ac572..1b08eace5 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts @@ -6,10 +6,12 @@ import { DateFilter, FloatFilter, RecordGqlOperationFilter, + RelationFilter, StringFilter, URLFilter, UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; +import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { Field } from '~/generated/graphql'; @@ -24,6 +26,200 @@ export type ObjectDropdownFilter = Omit & { }; }; +const applyEmptyFilters = ( + operand: ViewFilterOperand, + correspondingField: Pick, + objectRecordFilters: RecordGqlOperationFilter[], + filterType: FilterType, +) => { + let emptyRecordFilter: RecordGqlOperationFilter = {}; + + switch (filterType) { + case 'TEXT': + case 'EMAIL': + case 'PHONE': + emptyRecordFilter = { + or: [ + { [correspondingField.name]: { ilike: '' } as StringFilter }, + { [correspondingField.name]: { is: 'NULL' } as StringFilter }, + ], + }; + break; + case 'CURRENCY': + emptyRecordFilter = { + or: [ + { + [correspondingField.name]: { + amountMicros: { is: 'NULL' }, + } as CurrencyFilter, + }, + ], + }; + break; + case 'FULL_NAME': { + const fullNameFilters = generateILikeFiltersForCompositeFields( + '', + correspondingField.name, + ['firstName', 'lastName'], + true, + ); + + emptyRecordFilter = { + and: fullNameFilters, + }; + break; + } + case 'LINK': + emptyRecordFilter = { + or: [ + { [correspondingField.name]: { url: { ilike: '' } } as URLFilter }, + { + [correspondingField.name]: { url: { is: 'NULL' } } as URLFilter, + }, + ], + }; + break; + case 'LINKS': { + const linksFilters = generateILikeFiltersForCompositeFields( + '', + correspondingField.name, + ['primaryLinkLabel', 'primaryLinkUrl'], + true, + ); + + emptyRecordFilter = { + and: linksFilters, + }; + break; + } + case 'ADDRESS': + emptyRecordFilter = { + and: [ + { + or: [ + { + [correspondingField.name]: { + addressStreet1: { ilike: '' }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressStreet1: { is: 'NULL' }, + } as AddressFilter, + }, + ], + }, + { + or: [ + { + [correspondingField.name]: { + addressStreet2: { ilike: '' }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressStreet2: { is: 'NULL' }, + } as AddressFilter, + }, + ], + }, + { + or: [ + { + [correspondingField.name]: { + addressCity: { ilike: '' }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressCity: { is: 'NULL' }, + } as AddressFilter, + }, + ], + }, + { + or: [ + { + [correspondingField.name]: { + addressState: { ilike: '' }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressState: { is: 'NULL' }, + } as AddressFilter, + }, + ], + }, + { + or: [ + { + [correspondingField.name]: { + addressCountry: { ilike: '' }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressCountry: { is: 'NULL' }, + } as AddressFilter, + }, + ], + }, + { + or: [ + { + [correspondingField.name]: { + addressPostcode: { ilike: '' }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressPostcode: { is: 'NULL' }, + } as AddressFilter, + }, + ], + }, + ], + }; + break; + case 'NUMBER': + emptyRecordFilter = { + [correspondingField.name]: { is: 'NULL' } as FloatFilter, + }; + break; + case 'DATE_TIME': + emptyRecordFilter = { + [correspondingField.name]: { is: 'NULL' } as DateFilter, + }; + break; + case 'SELECT': + emptyRecordFilter = { + [correspondingField.name]: { is: 'NULL' } as UUIDFilter, + }; + break; + case 'RELATION': + emptyRecordFilter = { + [correspondingField.name + 'Id']: { is: 'NULL' } as RelationFilter, + }; + break; + default: + throw new Error(`Unsupported empty filter type ${filterType}`); + } + + switch (operand) { + case ViewFilterOperand.IsEmpty: + objectRecordFilters.push(emptyRecordFilter); + break; + case ViewFilterOperand.IsNotEmpty: + objectRecordFilters.push({ + not: emptyRecordFilter, + }); + break; + default: + throw new Error(`Unknown operand ${operand} for ${filterType} filter`); + } +}; + export const turnObjectDropdownFilterIntoQueryFilter = ( rawUIFilters: ObjectDropdownFilter[], fields: Pick[], @@ -35,12 +231,19 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( (field) => field.id === rawUIFilter.fieldMetadataId, ); + const isEmptyOperand = [ + ViewFilterOperand.IsEmpty, + ViewFilterOperand.IsNotEmpty, + ].includes(rawUIFilter.operand); + if (!correspondingField) { continue; } - if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') { - continue; + if (!isEmptyOperand) { + if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') { + continue; + } } switch (rawUIFilter.definition.type) { @@ -64,6 +267,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }, }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -86,6 +298,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } as DateFilter, }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -108,6 +329,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } as FloatFilter, }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -115,39 +345,57 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } break; case 'RELATION': { - try { - JSON.parse(rawUIFilter.value); - } catch (e) { - throw new Error( - `Cannot parse filter value for RELATION filter : "${rawUIFilter.value}"`, - ); - } + 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[]; + 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 UUIDFilter, - }); - break; - case ViewFilterOperand.IsNot: - if (parsedRecordIds.length > 0) { + if (parsedRecordIds.length > 0) { + switch (rawUIFilter.operand) { + case ViewFilterOperand.Is: objectRecordFilters.push({ - not: { - [correspondingField.name + 'Id']: { - in: parsedRecordIds, - } as UUIDFilter, - }, + [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.type, + ); break; default: throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, + `Unknown empty operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, ); } } @@ -169,6 +417,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } as CurrencyFilter, }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -197,6 +454,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }, }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -224,6 +490,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }), }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -231,7 +506,6 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } break; } - case 'FULL_NAME': { const fullNameFilters = generateILikeFiltersForCompositeFields( rawUIFilter.value, @@ -253,6 +527,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }), }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -286,6 +569,27 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }, } 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, + }, ], }); break; @@ -322,6 +626,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( ], }); break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; default: throw new Error( `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, @@ -329,6 +642,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( } break; case 'SELECT': { + if (isEmptyOperand) { + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition.type, + ); + break; + } const stringifiedSelectValues = rawUIFilter.value; let parsedOptionValues: string[] = []; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts index 170260764..b7dfc5860 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts @@ -49,7 +49,7 @@ describe('generateCsv', () => { }, ]; const csv = generateCsv({ columns, rows }); - expect(csv).toEqual(`id,Foo,Empty,Nested Foo,Nested Nested,Relation + expect(csv).toEqual(`Id,Foo,Empty,Nested Foo,Nested Nested,Relation 1,some field,,foo,nested,a relation`); }); }); diff --git a/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts b/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts index fa1a27d68..025d0085d 100644 --- a/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts +++ b/packages/twenty-front/src/modules/views/types/ViewFilterOperand.ts @@ -6,4 +6,6 @@ export enum ViewFilterOperand { GreaterThan = 'greaterThan', Contains = 'contains', DoesNotContain = 'doesNotContain', + IsEmpty = 'isEmpty', + IsNotEmpty = 'isNotEmpty', } diff --git a/packages/twenty-front/src/utils/array/generateILikeFiltersForCompositeFields.ts b/packages/twenty-front/src/utils/array/generateILikeFiltersForCompositeFields.ts index 4d9297693..a5a2883e7 100644 --- a/packages/twenty-front/src/utils/array/generateILikeFiltersForCompositeFields.ts +++ b/packages/twenty-front/src/utils/array/generateILikeFiltersForCompositeFields.ts @@ -4,7 +4,31 @@ export const generateILikeFiltersForCompositeFields = ( filterString: string, baseFieldName: string, subFields: string[], + emptyCheck = false, ) => { + if (emptyCheck) { + return subFields.map((subField) => { + return { + or: [ + { + [baseFieldName]: { + [subField]: { + is: 'NULL', + }, + }, + }, + { + [baseFieldName]: { + [subField]: { + ilike: '', + }, + }, + }, + ], + }; + }); + } + return filterString .split(' ') .reduce((previousValue: RecordGqlOperationFilter[], currentValue) => { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts index 4a07ee358..f86ac299d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts @@ -199,14 +199,20 @@ export class QueryRunnerArgsFactory { if (!fieldMetadata) { return value; } + switch (fieldMetadata.type) { - case 'NUMBER': - return Object.fromEntries( - Object.entries(value).map(([filterKey, filterValue]) => [ - filterKey, - Number(filterValue), - ]), - ); + case 'NUMBER': { + if (value?.is === 'NULL') { + return value; + } else { + return Object.fromEntries( + Object.entries(value).map(([filterKey, filterValue]) => [ + filterKey, + Number(filterValue), + ]), + ); + } + } default: return value; }