diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts
index 2205cd82af..bbe5122efc 100644
--- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts
+++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts
@@ -61,7 +61,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => {
result.current;
expect(columnDefinitions.length).toBe(21);
- expect(filterDefinitions.length).toBe(14);
+ expect(filterDefinitions.length).toBe(15);
expect(sortDefinitions.length).toBe(14);
});
});
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts
index 42734cb92f..94a1b27469 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts
@@ -35,6 +35,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Address,
FieldMetadataType.Relation,
FieldMetadataType.Select,
+ FieldMetadataType.MultiSelect,
FieldMetadataType.Currency,
FieldMetadataType.Rating,
FieldMetadataType.Actor,
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 9393d98b84..df189bd62a 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
@@ -21,11 +21,7 @@ export type BooleanFilter = {
export type StringFilter = {
eq?: string;
- gt?: string;
- gte?: string;
in?: string[];
- lt?: string;
- lte?: string;
neq?: string;
startsWith?: string;
like?: string;
@@ -35,6 +31,12 @@ export type StringFilter = {
is?: IsFilter;
};
+export type RatingFilter = {
+ eq?: string;
+ in?: string[];
+ is?: IsFilter;
+};
+
export type FloatFilter = {
eq?: number;
gt?: number;
@@ -104,10 +106,21 @@ export type PhonesFilter = {
primaryPhoneCountryCode?: StringFilter;
};
-export type ArrayFilter = {
- contains?: string[];
- not_contains?: string[];
+export type SelectFilter = {
is?: IsFilter;
+ in?: string[];
+};
+
+export type MultiSelectFilter = {
+ is?: IsFilter;
+ isEmptyArray?: boolean;
+ containsAny?: string[];
+};
+
+export type ArrayFilter = {
+ is?: IsFilter;
+ isEmptyArray?: boolean;
+ containsIlike?: string;
};
export type RawJsonFilter = {
diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx
index 050fb90218..7d5bcf561c 100644
--- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx
@@ -93,7 +93,9 @@ export const ObjectFilterDropdownFilterInput = ({
>
)}
- {filterDefinitionUsedInDropdown.type === 'SELECT' && (
+ {['SELECT', 'MULTI_SELECT'].includes(
+ filterDefinitionUsedInDropdown.type,
+ ) && (
<>
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 993c109165..897139af94 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
@@ -58,8 +58,14 @@ export const getOperandsForFilterDefinition = (
];
case 'RELATION':
return [...relationOperands, ...emptyOperands];
+ case 'MULTI_SELECT':
+ return [
+ ViewFilterOperand.Contains,
+ ViewFilterOperand.DoesNotContain,
+ ...emptyOperands,
+ ];
case 'SELECT':
- return [...relationOperands];
+ return [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands];
case 'ACTOR': {
if (isActorSourceCompositeFilter(filterDefinition)) {
return [
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts
index b6949ac986..ef396ebfc3 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts
@@ -158,62 +158,6 @@ describe('isMatchingStringFilter', () => {
});
});
- describe('gt', () => {
- it('value is greater than gt filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { gt: 'a' }, value: 'b' }),
- ).toBe(true);
- });
-
- it('value is not greater than gt filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { gt: 'b' }, value: 'a' }),
- ).toBe(false);
- });
- });
-
- describe('gte', () => {
- it('value is greater than or equal to gte filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { gte: 'a' }, value: 'a' }),
- ).toBe(true);
- });
-
- it('value is not greater than or equal to gte filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { gte: 'b' }, value: 'a' }),
- ).toBe(false);
- });
- });
-
- describe('lt', () => {
- it('value is less than lt filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { lt: 'b' }, value: 'a' }),
- ).toBe(true);
- });
-
- it('value is not less than lt filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { lt: 'a' }, value: 'b' }),
- ).toBe(false);
- });
- });
-
- describe('lte', () => {
- it('value is less than or equal to lte filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'a' }),
- ).toBe(true);
- });
-
- it('value is not less than or equal to lte filter', () => {
- expect(
- isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'b' }),
- ).toBe(false);
- });
- });
-
describe('startsWith', () => {
it('value starts with the startsWith filter', () => {
expect(
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts
index c57bce3b4f..10abe10295 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts
@@ -3,15 +3,18 @@ import { isNonEmptyString } from '@sniptt/guards';
import {
ActorFilter,
AddressFilter,
+ ArrayFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
+ MultiSelectFilter,
+ RatingFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
+ SelectFilter,
StringFilter,
- UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
@@ -241,7 +244,7 @@ const computeFilterRecordGqlOperationFilter = (
return {
[correspondingField.name]: {
eq: convertRatingToRatingValue(parseFloat(filter.value)),
- } as StringFilter,
+ } as RatingFilter,
};
case ViewFilterOperand.GreaterThan:
return {
@@ -249,7 +252,7 @@ const computeFilterRecordGqlOperationFilter = (
in: convertGreaterThanRatingToArrayOfRatingValues(
parseFloat(filter.value),
),
- } as StringFilter,
+ } as RatingFilter,
};
case ViewFilterOperand.LessThan:
return {
@@ -257,7 +260,7 @@ const computeFilterRecordGqlOperationFilter = (
in: convertLessThanRatingToArrayOfRatingValues(
parseFloat(filter.value),
),
- } as StringFilter,
+ } as RatingFilter,
};
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
@@ -309,30 +312,28 @@ const computeFilterRecordGqlOperationFilter = (
const parsedRecordIds = JSON.parse(filter.value) as string[];
- if (parsedRecordIds.length > 0) {
- switch (filter.operand) {
- case ViewFilterOperand.Is:
- return {
+ if (parsedRecordIds.length === 0) return;
+ switch (filter.operand) {
+ case ViewFilterOperand.Is:
+ return {
+ [correspondingField.name + 'Id']: {
+ in: parsedRecordIds,
+ } as RelationFilter,
+ };
+ case ViewFilterOperand.IsNot: {
+ if (parsedRecordIds.length === 0) return;
+ return {
+ not: {
[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`,
- );
+ },
+ };
}
+ default:
+ throw new Error(
+ `Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
+ );
}
} else {
switch (filter.operand) {
@@ -349,7 +350,6 @@ const computeFilterRecordGqlOperationFilter = (
);
}
}
- break;
}
case 'CURRENCY':
switch (filter.operand) {
@@ -601,6 +601,56 @@ const computeFilterRecordGqlOperationFilter = (
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
);
}
+ case 'MULTI_SELECT': {
+ if (isEmptyOperand) {
+ return getEmptyRecordGqlOperationFilter(
+ filter.operand,
+ correspondingField,
+ filter.definition,
+ );
+ }
+
+ const options = resolveFilterValue(
+ filter as Filter & { definition: { type: 'MULTI_SELECT' } },
+ );
+
+ if (options.length === 0) return;
+
+ switch (filter.operand) {
+ case ViewFilterOperand.Contains:
+ return {
+ [correspondingField.name]: {
+ containsAny: options,
+ } as MultiSelectFilter,
+ };
+ case ViewFilterOperand.DoesNotContain:
+ return {
+ or: [
+ {
+ not: {
+ [correspondingField.name]: {
+ containsAny: options,
+ } as MultiSelectFilter,
+ },
+ },
+ {
+ [correspondingField.name]: {
+ isEmptyArray: true,
+ } as MultiSelectFilter,
+ },
+ {
+ [correspondingField.name]: {
+ is: 'NULL',
+ } as MultiSelectFilter,
+ },
+ ],
+ };
+ default:
+ throw new Error(
+ `Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
+ );
+ }
+ }
case 'SELECT': {
if (isEmptyOperand) {
return getEmptyRecordGqlOperationFilter(
@@ -609,44 +659,61 @@ const computeFilterRecordGqlOperationFilter = (
filter.definition,
);
}
- const stringifiedSelectValues = filter.value;
- let parsedOptionValues: string[] = [];
+ const options = resolveFilterValue(
+ filter as Filter & { definition: { type: 'SELECT' } },
+ );
- if (!isNonEmptyString(stringifiedSelectValues)) {
- break;
- }
+ if (options.length === 0) return;
- 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 {
+ switch (filter.operand) {
+ case ViewFilterOperand.Is:
+ return {
+ [correspondingField.name]: {
+ in: options,
+ } as SelectFilter,
+ };
+ case ViewFilterOperand.IsNot:
+ return {
+ not: {
[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`,
- );
- }
+ in: options,
+ } as SelectFilter,
+ },
+ };
+ default:
+ throw new Error(
+ `Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
+ );
+ }
+ }
+ case 'ARRAY': {
+ switch (filter.operand) {
+ case ViewFilterOperand.Contains:
+ return {
+ [correspondingField.name]: {
+ containsIlike: `%${filter.value}%`,
+ } as ArrayFilter,
+ };
+ case ViewFilterOperand.DoesNotContain:
+ return {
+ not: {
+ [correspondingField.name]: {
+ containsIlike: `%${filter.value}%`,
+ } as ArrayFilter,
+ },
+ };
+ 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`,
+ );
}
- break;
}
// TODO: fix this with a new composite field in ViewFilter entity
case 'ACTOR': {
@@ -665,18 +732,17 @@ const computeFilterRecordGqlOperationFilter = (
case ViewFilterOperand.IsNot: {
const parsedRecordIds = JSON.parse(filter.value) as string[];
- if (parsedRecordIds.length > 0) {
- return {
- not: {
- [correspondingField.name]: {
- source: {
- in: parsedRecordIds,
- } as RelationFilter,
- },
+ if (parsedRecordIds.length === 0) return;
+
+ return {
+ not: {
+ [correspondingField.name]: {
+ source: {
+ in: parsedRecordIds,
+ } as RelationFilter,
},
- };
- }
- break;
+ },
+ };
}
case ViewFilterOperand.Contains:
return {
@@ -716,7 +782,6 @@ const computeFilterRecordGqlOperationFilter = (
`Unknown operand ${filter.operand} for ${filter.definition.label} filter`,
);
}
- break;
}
case 'EMAILS':
switch (filter.operand) {
@@ -806,7 +871,7 @@ const computeViewFilterGroupRecordGqlOperationFilter = (
);
if (!currentViewFilterGroup) {
- return undefined;
+ return;
}
const groupFilters = filters.filter(
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts
index f670e46e0f..b2cce9468b 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts
@@ -6,12 +6,14 @@ import {
DateFilter,
EmailsFilter,
FloatFilter,
+ MultiSelectFilter,
+ RatingFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
+ SelectFilter,
StringFilter,
URLFilter,
- UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
@@ -255,7 +257,7 @@ export const getEmptyRecordGqlOperationFilter = (
break;
case 'RATING':
emptyRecordFilter = {
- [correspondingField.name]: { is: 'NULL' } as StringFilter,
+ [correspondingField.name]: { is: 'NULL' } as RatingFilter,
};
break;
case 'DATE':
@@ -266,7 +268,21 @@ export const getEmptyRecordGqlOperationFilter = (
break;
case 'SELECT':
emptyRecordFilter = {
- [correspondingField.name]: { is: 'NULL' } as UUIDFilter,
+ [correspondingField.name]: { is: 'NULL' } as SelectFilter,
+ };
+ break;
+ case 'MULTI_SELECT':
+ emptyRecordFilter = {
+ or: [
+ {
+ [correspondingField.name]: { is: 'NULL' } as MultiSelectFilter,
+ },
+ {
+ [correspondingField.name]: {
+ isEmptyArray: true,
+ } as MultiSelectFilter,
+ },
+ ],
};
break;
case 'RELATION':
@@ -296,6 +312,9 @@ export const getEmptyRecordGqlOperationFilter = (
{
[correspondingField.name]: { is: 'NULL' } as ArrayFilter,
},
+ {
+ [correspondingField.name]: { isEmptyArray: true } as ArrayFilter,
+ },
],
};
break;
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts
index 7578a04aac..8cfd18a353 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts
@@ -5,19 +5,9 @@ export const isMatchingArrayFilter = ({
value,
}: {
arrayFilter: ArrayFilter;
- value: string[];
+ value: string[] | null;
}) => {
- if (value === null || !Array.isArray(value)) {
- return false;
- }
-
switch (true) {
- case arrayFilter.contains !== undefined: {
- return arrayFilter.contains.every((item) => value.includes(item));
- }
- case arrayFilter.not_contains !== undefined: {
- return !arrayFilter.not_contains.some((item) => value.includes(item));
- }
case arrayFilter.is !== undefined: {
if (arrayFilter.is === 'NULL') {
return value === null;
@@ -25,6 +15,16 @@ export const isMatchingArrayFilter = ({
return value !== null;
}
}
+ case arrayFilter.isEmptyArray !== undefined: {
+ return Array.isArray(value) && value.length === 0;
+ }
+ case arrayFilter.containsIlike !== undefined: {
+ const searchTerm = arrayFilter.containsIlike.toLowerCase();
+ return (
+ Array.isArray(value) &&
+ value.some((item) => item.toLowerCase().includes(searchTerm))
+ );
+ }
default: {
throw new Error(
`Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`,
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts
new file mode 100644
index 0000000000..c26572e5e1
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts
@@ -0,0 +1,35 @@
+import { MultiSelectFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
+
+export const isMatchingMultiSelectFilter = ({
+ multiSelectFilter,
+ value,
+}: {
+ multiSelectFilter: MultiSelectFilter;
+ value: string[] | null;
+}) => {
+ switch (true) {
+ case multiSelectFilter.containsAny !== undefined: {
+ return (
+ Array.isArray(value) &&
+ multiSelectFilter.containsAny.every((item) => value.includes(item))
+ );
+ }
+ case multiSelectFilter.isEmptyArray !== undefined: {
+ return Array.isArray(value) && value.length === 0;
+ }
+ case multiSelectFilter.is !== undefined: {
+ if (multiSelectFilter.is === 'NULL') {
+ return value === null;
+ } else {
+ return value !== null;
+ }
+ }
+ default: {
+ throw new Error(
+ `Unexpected value for multi-select filter: ${JSON.stringify(
+ multiSelectFilter,
+ )}`,
+ );
+ }
+ }
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts
new file mode 100644
index 0000000000..6f22b5e62d
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts
@@ -0,0 +1,30 @@
+import { RatingFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
+
+export const isMatchingRatingFilter = ({
+ ratingFilter,
+ value,
+}: {
+ ratingFilter: RatingFilter;
+ value: string | null;
+}) => {
+ switch (true) {
+ case ratingFilter.eq !== undefined: {
+ return value === ratingFilter.eq;
+ }
+ case ratingFilter.in !== undefined: {
+ return value !== null && ratingFilter.in.includes(value);
+ }
+ case ratingFilter.is !== undefined: {
+ if (ratingFilter.is === 'NULL') {
+ return value === null;
+ } else {
+ return value !== null;
+ }
+ }
+ default: {
+ throw new Error(
+ `Unexpected value for rating filter : ${JSON.stringify(ratingFilter)}`,
+ );
+ }
+ }
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts
new file mode 100644
index 0000000000..c564f63a14
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts
@@ -0,0 +1,27 @@
+import { SelectFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
+
+export const isMatchingSelectFilter = ({
+ selectFilter,
+ value,
+}: {
+ selectFilter: SelectFilter;
+ value: string;
+}) => {
+ switch (true) {
+ case selectFilter.in !== undefined: {
+ return selectFilter.in.includes(value);
+ }
+ case selectFilter.is !== undefined: {
+ if (selectFilter.is === 'NULL') {
+ return value === null;
+ } else {
+ return value !== null;
+ }
+ }
+ default: {
+ throw new Error(
+ `Unexpected value for select filter : ${JSON.stringify(selectFilter)}`,
+ );
+ }
+ }
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts
index 053be8cdec..5c80c36fe9 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts
@@ -48,18 +48,6 @@ export const isMatchingStringFilter = ({
return regexCaseInsensitive.test(value);
}
- case stringFilter.gt !== undefined: {
- return value > stringFilter.gt;
- }
- case stringFilter.gte !== undefined: {
- return value >= stringFilter.gte;
- }
- case stringFilter.lt !== undefined: {
- return value < stringFilter.lt;
- }
- case stringFilter.lte !== undefined: {
- return value <= stringFilter.lte;
- }
case stringFilter.startsWith !== undefined: {
return value.startsWith(stringFilter.startsWith);
}
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
index c2a5da47fd..2b01d2405f 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
@@ -14,11 +14,14 @@ import {
FullNameFilter,
LeafObjectRecordFilter,
LinksFilter,
+ MultiSelectFilter,
NotObjectRecordFilter,
OrObjectRecordFilter,
PhonesFilter,
+ RatingFilter,
RawJsonFilter,
RecordGqlOperationFilter,
+ SelectFilter,
StringFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
@@ -27,7 +30,10 @@ import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isM
import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
+import { isMatchingMultiSelectFilter } from '@/object-record/record-filter/utils/isMatchingMultiSelectFilter';
+import { isMatchingRatingFilter } from '@/object-record/record-filter/utils/isMatchingRatingFilter';
import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter';
+import { isMatchingSelectFilter } from '@/object-record/record-filter/utils/isMatchingSelectFilter';
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -160,15 +166,27 @@ export const isRecordMatchingFilter = ({
}
switch (objectMetadataField.type) {
- case FieldMetadataType.Select:
case FieldMetadataType.Rating:
- case FieldMetadataType.MultiSelect:
+ return isMatchingRatingFilter({
+ ratingFilter: filterValue as RatingFilter,
+ value: record[filterKey],
+ });
case FieldMetadataType.Text: {
return isMatchingStringFilter({
stringFilter: filterValue as StringFilter,
value: record[filterKey],
});
}
+ case FieldMetadataType.Select:
+ return isMatchingSelectFilter({
+ selectFilter: filterValue as SelectFilter,
+ value: record[filterKey],
+ });
+ case FieldMetadataType.MultiSelect:
+ return isMatchingMultiSelectFilter({
+ multiSelectFilter: filterValue as MultiSelectFilter,
+ value: record[filterKey],
+ });
case FieldMetadataType.Array: {
return isMatchingArrayFilter({
arrayFilter: filterValue as ArrayFilter,
diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx
index ed632475f0..4e3836f6ef 100644
--- a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx
+++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx
@@ -75,7 +75,10 @@ export const ViewBarFilterEffect = ({
? JSON.parse(viewFilterUsedInDropdown.value)
: [];
setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecords);
- } else if (filterDefinitionUsedInDropdown?.type === 'SELECT') {
+ } else if (
+ isDefined(filterDefinitionUsedInDropdown) &&
+ ['SELECT', 'MULTI_SELECT'].includes(filterDefinitionUsedInDropdown.type)
+ ) {
const viewFilterUsedInDropdown =
currentViewWithCombinedFiltersAndSorts?.viewFilters.find(
(filter) =>
diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts
index c5c4e06450..47a160cbe8 100644
--- a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts
+++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts
@@ -2,6 +2,7 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter';
import { FilterableFieldType } from '@/object-record/object-filter-dropdown/types/FilterableFieldType';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { resolveNumberViewFilterValue } from '@/views/view-filter-value/utils/resolveNumberViewFilterValue';
+import { resolveSelectViewFilterValue } from '@/views/view-filter-value/utils/resolveSelectViewFilterValue';
import {
resolveDateViewFilterValue,
ResolvedDateViewFilterValue,
@@ -14,7 +15,9 @@ type ResolvedFilterValue<
? ResolvedDateViewFilterValue
: T extends 'NUMBER'
? ReturnType
- : string;
+ : T extends 'SELECT' | 'MULTI_SELECT'
+ ? string[]
+ : string;
type PartialFilter<
T extends FilterableFieldType,
@@ -36,6 +39,9 @@ export const resolveFilterValue = <
return resolveDateViewFilterValue(filter) as ResolvedFilterValue;
case 'NUMBER':
return resolveNumberViewFilterValue(filter) as ResolvedFilterValue;
+ case 'SELECT':
+ case 'MULTI_SELECT':
+ return resolveSelectViewFilterValue(filter) as ResolvedFilterValue;
default:
return filter.value as ResolvedFilterValue;
}
diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts
new file mode 100644
index 0000000000..c5253fb2e1
--- /dev/null
+++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts
@@ -0,0 +1,19 @@
+import { ViewFilter } from '@/views/types/ViewFilter';
+import { z } from 'zod';
+
+const selectViewFilterValueSchema = z
+ .string()
+ .transform((val) => (val === '' ? [] : JSON.parse(val)))
+ .refine(
+ (parsed) =>
+ Array.isArray(parsed) && parsed.every((item) => typeof item === 'string'),
+ {
+ message: 'Expected an array of strings',
+ },
+ );
+
+export const resolveSelectViewFilterValue = (
+ viewFilter: Pick,
+) => {
+ return selectViewFilterValueSchema.parse(viewFilter.value);
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts
index 95fb650826..3cfc53b4a7 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts
@@ -13,7 +13,7 @@ import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metada
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { capitalize } from 'src/utils/capitalize';
-const ARRAY_OPERATORS = ['in', 'contains', 'not_contains'];
+const ARRAY_OPERATORS = ['in', 'contains', 'notContains'];
export class GraphqlQueryFilterFieldParser {
private fieldMetadataMapByName: FieldMetadataMap;
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts
index aae3f9b001..0985a2eb58 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts
@@ -19,6 +19,11 @@ export const computeWhereConditionParts = (
const uuid = Math.random().toString(36).slice(2, 7);
switch (operator) {
+ case 'isEmptyArray':
+ return {
+ sql: `"${objectNameSingular}"."${key}" = '{}'`,
+ params: {},
+ };
case 'eq':
return {
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`,
@@ -84,10 +89,19 @@ export const computeWhereConditionParts = (
sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`,
params: { [`${key}${uuid}`]: value },
};
-
- case 'not_contains':
+ case 'notContains':
return {
- sql: `NOT ("${objectNameSingular}"."${key}" && ARRAY[:...${key}${uuid}])`,
+ sql: `NOT ("${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[])`,
+ params: { [`${key}${uuid}`]: value },
+ };
+ case 'containsAny':
+ return {
+ sql: `"${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[]`,
+ params: { [`${key}${uuid}`]: value },
+ };
+ case 'containsIlike':
+ return {
+ sql: `EXISTS (SELECT 1 FROM unnest("${objectNameSingular}"."${key}") AS elem WHERE elem ILIKE :${key}${uuid})`,
params: { [`${key}${uuid}`]: value },
};
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts
index 5f2d696a0a..97eb3c6b19 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts
@@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
-import { GraphQLInputObjectType, GraphQLInputType, GraphQLList } from 'graphql';
+import {
+ GraphQLBoolean,
+ GraphQLInputObjectType,
+ GraphQLInputType,
+ GraphQLList,
+} from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
@@ -103,7 +108,9 @@ export class InputTypeFactory {
eq: { type: enumType },
neq: { type: enumType },
in: { type: new GraphQLList(enumType) },
+ containsAny: { type: new GraphQLList(enumType) },
is: { type: FilterIs },
+ isEmptyArray: { type: GraphQLBoolean },
}),
});
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts
index 3cd24cbbaa..118eb898fc 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts
@@ -1,12 +1,12 @@
-import { GraphQLInputObjectType, GraphQLList, GraphQLString } from 'graphql';
+import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString } from 'graphql';
import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
export const ArrayFilterType = new GraphQLInputObjectType({
name: 'ArrayFilter',
fields: {
- contains: { type: new GraphQLList(GraphQLString) },
- not_contains: { type: new GraphQLList(GraphQLString) },
+ containsIlike: { type: GraphQLString },
is: { type: FilterIs },
+ isEmptyArray: { type: GraphQLBoolean },
},
});
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts
new file mode 100644
index 0000000000..21798eaed9
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts
@@ -0,0 +1,17 @@
+import {
+ GraphQLBoolean,
+ GraphQLInputObjectType,
+ GraphQLList,
+ GraphQLString,
+} from 'graphql';
+
+import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
+
+export const MultiSelectFilterType = new GraphQLInputObjectType({
+ name: 'MultiSelectFilter',
+ fields: {
+ containsAny: { type: new GraphQLList(GraphQLString) },
+ is: { type: FilterIs },
+ isEmptyArray: { type: GraphQLBoolean },
+ },
+});
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts
new file mode 100644
index 0000000000..7f21ae426c
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts
@@ -0,0 +1,11 @@
+import { GraphQLInputObjectType, GraphQLList, GraphQLString } from 'graphql';
+
+import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
+
+export const SelectFilterType = new GraphQLInputObjectType({
+ name: 'SelectFilter',
+ fields: {
+ in: { type: new GraphQLList(GraphQLString) },
+ is: { type: FilterIs },
+ },
+});
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts
index 429cd195fa..aa3259aea9 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts
@@ -27,6 +27,8 @@ import {
StringFilterType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input';
import { IDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/id-filter.input-type';
+import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
+import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
import {
BigFloatScalarType,
UUIDScalarType,
@@ -115,6 +117,8 @@ export class TypeMapperService {
[FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.ARRAY, ArrayFilterType],
+ [FieldMetadataType.MULTI_SELECT, MultiSelectFilterType],
+ [FieldMetadataType.SELECT, SelectFilterType],
[FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType
]);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts
index 88e8091a04..4797b9dbba 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts
@@ -58,7 +58,9 @@ export const generateFields = <
? {
nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue,
- isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
+ isArray:
+ kind !== InputTypeDefinitionKind.Filter &&
+ fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings,
isIdField: fieldMetadata.name === 'id',
}