Fix multi select filtering (#5358) (#8452)

Allow filtering by multi-select fields.

<img width="1053" alt="Screenshot 2024-11-11 at 11 54 45"
src="https://github.com/user-attachments/assets/a79b2251-94e3-48f8-abda-e808103a6c39">

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
ad-elias 2024-11-17 15:27:38 +01:00 committed by GitHub
parent ac1197afe1
commit badebc513f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 408 additions and 177 deletions

View File

@ -61,7 +61,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => {
result.current; result.current;
expect(columnDefinitions.length).toBe(21); expect(columnDefinitions.length).toBe(21);
expect(filterDefinitions.length).toBe(14); expect(filterDefinitions.length).toBe(15);
expect(sortDefinitions.length).toBe(14); expect(sortDefinitions.length).toBe(14);
}); });
}); });

View File

@ -35,6 +35,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Address, FieldMetadataType.Address,
FieldMetadataType.Relation, FieldMetadataType.Relation,
FieldMetadataType.Select, FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.Currency, FieldMetadataType.Currency,
FieldMetadataType.Rating, FieldMetadataType.Rating,
FieldMetadataType.Actor, FieldMetadataType.Actor,

View File

@ -21,11 +21,7 @@ export type BooleanFilter = {
export type StringFilter = { export type StringFilter = {
eq?: string; eq?: string;
gt?: string;
gte?: string;
in?: string[]; in?: string[];
lt?: string;
lte?: string;
neq?: string; neq?: string;
startsWith?: string; startsWith?: string;
like?: string; like?: string;
@ -35,6 +31,12 @@ export type StringFilter = {
is?: IsFilter; is?: IsFilter;
}; };
export type RatingFilter = {
eq?: string;
in?: string[];
is?: IsFilter;
};
export type FloatFilter = { export type FloatFilter = {
eq?: number; eq?: number;
gt?: number; gt?: number;
@ -104,10 +106,21 @@ export type PhonesFilter = {
primaryPhoneCountryCode?: StringFilter; primaryPhoneCountryCode?: StringFilter;
}; };
export type ArrayFilter = { export type SelectFilter = {
contains?: string[];
not_contains?: string[];
is?: IsFilter; 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 = { export type RawJsonFilter = {

View File

@ -93,7 +93,9 @@ export const ObjectFilterDropdownFilterInput = ({
<ObjectFilterDropdownSourceSelect /> <ObjectFilterDropdownSourceSelect />
</> </>
)} )}
{filterDefinitionUsedInDropdown.type === 'SELECT' && ( {['SELECT', 'MULTI_SELECT'].includes(
filterDefinitionUsedInDropdown.type,
) && (
<> <>
<ObjectFilterDropdownSearchInput /> <ObjectFilterDropdownSearchInput />
<ObjectFilterDropdownOptionSelect /> <ObjectFilterDropdownOptionSelect />

View File

@ -58,8 +58,14 @@ export const getOperandsForFilterDefinition = (
]; ];
case 'RELATION': case 'RELATION':
return [...relationOperands, ...emptyOperands]; return [...relationOperands, ...emptyOperands];
case 'MULTI_SELECT':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
];
case 'SELECT': case 'SELECT':
return [...relationOperands]; return [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands];
case 'ACTOR': { case 'ACTOR': {
if (isActorSourceCompositeFilter(filterDefinition)) { if (isActorSourceCompositeFilter(filterDefinition)) {
return [ return [

View File

@ -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', () => { describe('startsWith', () => {
it('value starts with the startsWith filter', () => { it('value starts with the startsWith filter', () => {
expect( expect(

View File

@ -3,15 +3,18 @@ import { isNonEmptyString } from '@sniptt/guards';
import { import {
ActorFilter, ActorFilter,
AddressFilter, AddressFilter,
ArrayFilter,
CurrencyFilter, CurrencyFilter,
DateFilter, DateFilter,
EmailsFilter, EmailsFilter,
FloatFilter, FloatFilter,
MultiSelectFilter,
RatingFilter,
RawJsonFilter, RawJsonFilter,
RecordGqlOperationFilter, RecordGqlOperationFilter,
RelationFilter, RelationFilter,
SelectFilter,
StringFilter, StringFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter'; } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql'; import { Field } from '~/generated/graphql';
@ -241,7 +244,7 @@ const computeFilterRecordGqlOperationFilter = (
return { return {
[correspondingField.name]: { [correspondingField.name]: {
eq: convertRatingToRatingValue(parseFloat(filter.value)), eq: convertRatingToRatingValue(parseFloat(filter.value)),
} as StringFilter, } as RatingFilter,
}; };
case ViewFilterOperand.GreaterThan: case ViewFilterOperand.GreaterThan:
return { return {
@ -249,7 +252,7 @@ const computeFilterRecordGqlOperationFilter = (
in: convertGreaterThanRatingToArrayOfRatingValues( in: convertGreaterThanRatingToArrayOfRatingValues(
parseFloat(filter.value), parseFloat(filter.value),
), ),
} as StringFilter, } as RatingFilter,
}; };
case ViewFilterOperand.LessThan: case ViewFilterOperand.LessThan:
return { return {
@ -257,7 +260,7 @@ const computeFilterRecordGqlOperationFilter = (
in: convertLessThanRatingToArrayOfRatingValues( in: convertLessThanRatingToArrayOfRatingValues(
parseFloat(filter.value), parseFloat(filter.value),
), ),
} as StringFilter, } as RatingFilter,
}; };
case ViewFilterOperand.IsEmpty: case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty: case ViewFilterOperand.IsNotEmpty:
@ -309,30 +312,28 @@ const computeFilterRecordGqlOperationFilter = (
const parsedRecordIds = JSON.parse(filter.value) as string[]; const parsedRecordIds = JSON.parse(filter.value) as string[];
if (parsedRecordIds.length > 0) { if (parsedRecordIds.length === 0) return;
switch (filter.operand) { switch (filter.operand) {
case ViewFilterOperand.Is: case ViewFilterOperand.Is:
return { return {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
};
case ViewFilterOperand.IsNot: {
if (parsedRecordIds.length === 0) return;
return {
not: {
[correspondingField.name + 'Id']: { [correspondingField.name + 'Id']: {
in: parsedRecordIds, in: parsedRecordIds,
} as RelationFilter, } 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 { } else {
switch (filter.operand) { switch (filter.operand) {
@ -349,7 +350,6 @@ const computeFilterRecordGqlOperationFilter = (
); );
} }
} }
break;
} }
case 'CURRENCY': case 'CURRENCY':
switch (filter.operand) { switch (filter.operand) {
@ -601,6 +601,56 @@ const computeFilterRecordGqlOperationFilter = (
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`, `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': { case 'SELECT': {
if (isEmptyOperand) { if (isEmptyOperand) {
return getEmptyRecordGqlOperationFilter( return getEmptyRecordGqlOperationFilter(
@ -609,44 +659,61 @@ const computeFilterRecordGqlOperationFilter = (
filter.definition, filter.definition,
); );
} }
const stringifiedSelectValues = filter.value; const options = resolveFilterValue(
let parsedOptionValues: string[] = []; filter as Filter & { definition: { type: 'SELECT' } },
);
if (!isNonEmptyString(stringifiedSelectValues)) { if (options.length === 0) return;
break;
}
try { switch (filter.operand) {
parsedOptionValues = JSON.parse(stringifiedSelectValues); case ViewFilterOperand.Is:
} catch (e) { return {
throw new Error( [correspondingField.name]: {
`Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`, in: options,
); } as SelectFilter,
} };
case ViewFilterOperand.IsNot:
if (parsedOptionValues.length > 0) { return {
switch (filter.operand) { not: {
case ViewFilterOperand.Is:
return {
[correspondingField.name]: { [correspondingField.name]: {
in: parsedOptionValues, in: options,
} as UUIDFilter, } as SelectFilter,
}; },
case ViewFilterOperand.IsNot: };
return { default:
not: { throw new Error(
[correspondingField.name]: { `Unknown operand ${filter.operand} for ${filter.definition.type} filter`,
in: parsedOptionValues, );
} as UUIDFilter, }
}, }
}; case 'ARRAY': {
default: switch (filter.operand) {
throw new Error( case ViewFilterOperand.Contains:
`Unknown operand ${filter.operand} for ${filter.definition.type} filter`, 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 // TODO: fix this with a new composite field in ViewFilter entity
case 'ACTOR': { case 'ACTOR': {
@ -665,18 +732,17 @@ const computeFilterRecordGqlOperationFilter = (
case ViewFilterOperand.IsNot: { case ViewFilterOperand.IsNot: {
const parsedRecordIds = JSON.parse(filter.value) as string[]; const parsedRecordIds = JSON.parse(filter.value) as string[];
if (parsedRecordIds.length > 0) { if (parsedRecordIds.length === 0) return;
return {
not: { return {
[correspondingField.name]: { not: {
source: { [correspondingField.name]: {
in: parsedRecordIds, source: {
} as RelationFilter, in: parsedRecordIds,
}, } as RelationFilter,
}, },
}; },
} };
break;
} }
case ViewFilterOperand.Contains: case ViewFilterOperand.Contains:
return { return {
@ -716,7 +782,6 @@ const computeFilterRecordGqlOperationFilter = (
`Unknown operand ${filter.operand} for ${filter.definition.label} filter`, `Unknown operand ${filter.operand} for ${filter.definition.label} filter`,
); );
} }
break;
} }
case 'EMAILS': case 'EMAILS':
switch (filter.operand) { switch (filter.operand) {
@ -806,7 +871,7 @@ const computeViewFilterGroupRecordGqlOperationFilter = (
); );
if (!currentViewFilterGroup) { if (!currentViewFilterGroup) {
return undefined; return;
} }
const groupFilters = filters.filter( const groupFilters = filters.filter(

View File

@ -6,12 +6,14 @@ import {
DateFilter, DateFilter,
EmailsFilter, EmailsFilter,
FloatFilter, FloatFilter,
MultiSelectFilter,
RatingFilter,
RawJsonFilter, RawJsonFilter,
RecordGqlOperationFilter, RecordGqlOperationFilter,
RelationFilter, RelationFilter,
SelectFilter,
StringFilter, StringFilter,
URLFilter, URLFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter'; } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
@ -255,7 +257,7 @@ export const getEmptyRecordGqlOperationFilter = (
break; break;
case 'RATING': case 'RATING':
emptyRecordFilter = { emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as StringFilter, [correspondingField.name]: { is: 'NULL' } as RatingFilter,
}; };
break; break;
case 'DATE': case 'DATE':
@ -266,7 +268,21 @@ export const getEmptyRecordGqlOperationFilter = (
break; break;
case 'SELECT': case 'SELECT':
emptyRecordFilter = { 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; break;
case 'RELATION': case 'RELATION':
@ -296,6 +312,9 @@ export const getEmptyRecordGqlOperationFilter = (
{ {
[correspondingField.name]: { is: 'NULL' } as ArrayFilter, [correspondingField.name]: { is: 'NULL' } as ArrayFilter,
}, },
{
[correspondingField.name]: { isEmptyArray: true } as ArrayFilter,
},
], ],
}; };
break; break;

View File

@ -5,19 +5,9 @@ export const isMatchingArrayFilter = ({
value, value,
}: { }: {
arrayFilter: ArrayFilter; arrayFilter: ArrayFilter;
value: string[]; value: string[] | null;
}) => { }) => {
if (value === null || !Array.isArray(value)) {
return false;
}
switch (true) { 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: { case arrayFilter.is !== undefined: {
if (arrayFilter.is === 'NULL') { if (arrayFilter.is === 'NULL') {
return value === null; return value === null;
@ -25,6 +15,16 @@ export const isMatchingArrayFilter = ({
return value !== null; 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: { default: {
throw new Error( throw new Error(
`Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`, `Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`,

View File

@ -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,
)}`,
);
}
}
};

View File

@ -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)}`,
);
}
}
};

View File

@ -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)}`,
);
}
}
};

View File

@ -48,18 +48,6 @@ export const isMatchingStringFilter = ({
return regexCaseInsensitive.test(value); 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: { case stringFilter.startsWith !== undefined: {
return value.startsWith(stringFilter.startsWith); return value.startsWith(stringFilter.startsWith);
} }

View File

@ -14,11 +14,14 @@ import {
FullNameFilter, FullNameFilter,
LeafObjectRecordFilter, LeafObjectRecordFilter,
LinksFilter, LinksFilter,
MultiSelectFilter,
NotObjectRecordFilter, NotObjectRecordFilter,
OrObjectRecordFilter, OrObjectRecordFilter,
PhonesFilter, PhonesFilter,
RatingFilter,
RawJsonFilter, RawJsonFilter,
RecordGqlOperationFilter, RecordGqlOperationFilter,
SelectFilter,
StringFilter, StringFilter,
UUIDFilter, UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter'; } 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 { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter'; import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter'; 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 { 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 { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -160,15 +166,27 @@ export const isRecordMatchingFilter = ({
} }
switch (objectMetadataField.type) { switch (objectMetadataField.type) {
case FieldMetadataType.Select:
case FieldMetadataType.Rating: case FieldMetadataType.Rating:
case FieldMetadataType.MultiSelect: return isMatchingRatingFilter({
ratingFilter: filterValue as RatingFilter,
value: record[filterKey],
});
case FieldMetadataType.Text: { case FieldMetadataType.Text: {
return isMatchingStringFilter({ return isMatchingStringFilter({
stringFilter: filterValue as StringFilter, stringFilter: filterValue as StringFilter,
value: record[filterKey], 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: { case FieldMetadataType.Array: {
return isMatchingArrayFilter({ return isMatchingArrayFilter({
arrayFilter: filterValue as ArrayFilter, arrayFilter: filterValue as ArrayFilter,

View File

@ -75,7 +75,10 @@ export const ViewBarFilterEffect = ({
? JSON.parse(viewFilterUsedInDropdown.value) ? JSON.parse(viewFilterUsedInDropdown.value)
: []; : [];
setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecords); setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecords);
} else if (filterDefinitionUsedInDropdown?.type === 'SELECT') { } else if (
isDefined(filterDefinitionUsedInDropdown) &&
['SELECT', 'MULTI_SELECT'].includes(filterDefinitionUsedInDropdown.type)
) {
const viewFilterUsedInDropdown = const viewFilterUsedInDropdown =
currentViewWithCombinedFiltersAndSorts?.viewFilters.find( currentViewWithCombinedFiltersAndSorts?.viewFilters.find(
(filter) => (filter) =>

View File

@ -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 { FilterableFieldType } from '@/object-record/object-filter-dropdown/types/FilterableFieldType';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { resolveNumberViewFilterValue } from '@/views/view-filter-value/utils/resolveNumberViewFilterValue'; import { resolveNumberViewFilterValue } from '@/views/view-filter-value/utils/resolveNumberViewFilterValue';
import { resolveSelectViewFilterValue } from '@/views/view-filter-value/utils/resolveSelectViewFilterValue';
import { import {
resolveDateViewFilterValue, resolveDateViewFilterValue,
ResolvedDateViewFilterValue, ResolvedDateViewFilterValue,
@ -14,7 +15,9 @@ type ResolvedFilterValue<
? ResolvedDateViewFilterValue<O> ? ResolvedDateViewFilterValue<O>
: T extends 'NUMBER' : T extends 'NUMBER'
? ReturnType<typeof resolveNumberViewFilterValue> ? ReturnType<typeof resolveNumberViewFilterValue>
: string; : T extends 'SELECT' | 'MULTI_SELECT'
? string[]
: string;
type PartialFilter< type PartialFilter<
T extends FilterableFieldType, T extends FilterableFieldType,
@ -36,6 +39,9 @@ export const resolveFilterValue = <
return resolveDateViewFilterValue(filter) as ResolvedFilterValue<T, O>; return resolveDateViewFilterValue(filter) as ResolvedFilterValue<T, O>;
case 'NUMBER': case 'NUMBER':
return resolveNumberViewFilterValue(filter) as ResolvedFilterValue<T, O>; return resolveNumberViewFilterValue(filter) as ResolvedFilterValue<T, O>;
case 'SELECT':
case 'MULTI_SELECT':
return resolveSelectViewFilterValue(filter) as ResolvedFilterValue<T, O>;
default: default:
return filter.value as ResolvedFilterValue<T, O>; return filter.value as ResolvedFilterValue<T, O>;
} }

View File

@ -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<ViewFilter, 'value'>,
) => {
return selectViewFilterValueSchema.parse(viewFilter.value);
};

View File

@ -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 { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { capitalize } from 'src/utils/capitalize'; import { capitalize } from 'src/utils/capitalize';
const ARRAY_OPERATORS = ['in', 'contains', 'not_contains']; const ARRAY_OPERATORS = ['in', 'contains', 'notContains'];
export class GraphqlQueryFilterFieldParser { export class GraphqlQueryFilterFieldParser {
private fieldMetadataMapByName: FieldMetadataMap; private fieldMetadataMapByName: FieldMetadataMap;

View File

@ -19,6 +19,11 @@ export const computeWhereConditionParts = (
const uuid = Math.random().toString(36).slice(2, 7); const uuid = Math.random().toString(36).slice(2, 7);
switch (operator) { switch (operator) {
case 'isEmptyArray':
return {
sql: `"${objectNameSingular}"."${key}" = '{}'`,
params: {},
};
case 'eq': case 'eq':
return { return {
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`, sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`,
@ -84,10 +89,19 @@ export const computeWhereConditionParts = (
sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`, sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`,
params: { [`${key}${uuid}`]: value }, params: { [`${key}${uuid}`]: value },
}; };
case 'notContains':
case 'not_contains':
return { 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 }, params: { [`${key}${uuid}`]: value },
}; };

View File

@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common'; 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'; 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 }, eq: { type: enumType },
neq: { type: enumType }, neq: { type: enumType },
in: { type: new GraphQLList(enumType) }, in: { type: new GraphQLList(enumType) },
containsAny: { type: new GraphQLList(enumType) },
is: { type: FilterIs }, is: { type: FilterIs },
isEmptyArray: { type: GraphQLBoolean },
}), }),
}); });
} }

View File

@ -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'; import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
export const ArrayFilterType = new GraphQLInputObjectType({ export const ArrayFilterType = new GraphQLInputObjectType({
name: 'ArrayFilter', name: 'ArrayFilter',
fields: { fields: {
contains: { type: new GraphQLList(GraphQLString) }, containsIlike: { type: GraphQLString },
not_contains: { type: new GraphQLList(GraphQLString) },
is: { type: FilterIs }, is: { type: FilterIs },
isEmptyArray: { type: GraphQLBoolean },
}, },
}); });

View File

@ -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 },
},
});

View File

@ -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 },
},
});

View File

@ -27,6 +27,8 @@ import {
StringFilterType, StringFilterType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input'; } 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 { 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 { import {
BigFloatScalarType, BigFloatScalarType,
UUIDScalarType, UUIDScalarType,
@ -115,6 +117,8 @@ export class TypeMapperService {
[FieldMetadataType.RAW_JSON, RawJsonFilterType], [FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType], [FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.ARRAY, ArrayFilterType], [FieldMetadataType.ARRAY, ArrayFilterType],
[FieldMetadataType.MULTI_SELECT, MultiSelectFilterType],
[FieldMetadataType.SELECT, SelectFilterType],
[FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType [FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType
]); ]);

View File

@ -58,7 +58,9 @@ export const generateFields = <
? { ? {
nullable: fieldMetadata.isNullable, nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue, defaultValue: fieldMetadata.defaultValue,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT, isArray:
kind !== InputTypeDefinitionKind.Filter &&
fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
settings: fieldMetadata.settings, settings: fieldMetadata.settings,
isIdField: fieldMetadata.name === 'id', isIdField: fieldMetadata.name === 'id',
} }