[GroupBy] Add views filters to groupBy query 2/2: handle anyFieldFilter (#14791)

Following https://github.com/twentyhq/twenty/pull/14762 

In this PR
- moved logic around any field filter formatting to twenty-shared
- added any field filter to the filters applied to groupBy when viewId
is defined
- added integration test
This commit is contained in:
Marie
2025-09-30 18:13:40 +02:00
committed by GitHub
parent 3927b36406
commit f33396e425
46 changed files with 805 additions and 549 deletions
@@ -0,0 +1,156 @@
export enum CurrencyCode {
AED = 'AED',
AFN = 'AFN',
ALL = 'ALL',
AMD = 'AMD',
ANG = 'ANG',
AOA = 'AOA',
ARS = 'ARS',
AUD = 'AUD',
AWG = 'AWG',
AZN = 'AZN',
BAM = 'BAM',
BBD = 'BBD',
BDT = 'BDT',
BGN = 'BGN',
BHD = 'BHD',
BIF = 'BIF',
BMD = 'BMD',
BND = 'BND',
BOB = 'BOB',
BRL = 'BRL',
BSD = 'BSD',
BTN = 'BTN',
BWP = 'BWP',
BYN = 'BYN',
BZD = 'BZD',
CAD = 'CAD',
CDF = 'CDF',
CHF = 'CHF',
CLP = 'CLP',
CNY = 'CNY',
COP = 'COP',
CRC = 'CRC',
CUP = 'CUP',
CVE = 'CVE',
CZK = 'CZK',
DJF = 'DJF',
DKK = 'DKK',
DOP = 'DOP',
DZD = 'DZD',
EGP = 'EGP',
ERN = 'ERN',
ETB = 'ETB',
EUR = 'EUR',
FJD = 'FJD',
FKP = 'FKP',
GBP = 'GBP',
GEL = 'GEL',
GHS = 'GHS',
GIP = 'GIP',
GMD = 'GMD',
GNF = 'GNF',
GTQ = 'GTQ',
GYD = 'GYD',
HKD = 'HKD',
HNL = 'HNL',
HTG = 'HTG',
HUF = 'HUF',
IDR = 'IDR',
ILS = 'ILS',
INR = 'INR',
IQD = 'IQD',
IRR = 'IRR',
ISK = 'ISK',
JMD = 'JMD',
JOD = 'JOD',
JPY = 'JPY',
KES = 'KES',
KGS = 'KGS',
KHR = 'KHR',
KMF = 'KMF',
KPW = 'KPW',
KRW = 'KRW',
KWD = 'KWD',
KYD = 'KYD',
KZT = 'KZT',
LAK = 'LAK',
LBP = 'LBP',
LKR = 'LKR',
LRD = 'LRD',
LSL = 'LSL',
LYD = 'LYD',
MAD = 'MAD',
MDL = 'MDL',
MGA = 'MGA',
MKD = 'MKD',
MMK = 'MMK',
MNT = 'MNT',
MOP = 'MOP',
MRU = 'MRU',
MUR = 'MUR',
MVR = 'MVR',
MWK = 'MWK',
MXN = 'MXN',
MYR = 'MYR',
MZN = 'MZN',
NAD = 'NAD',
NGN = 'NGN',
NIO = 'NIO',
NOK = 'NOK',
NPR = 'NPR',
NZD = 'NZD',
OMR = 'OMR',
PAB = 'PAB',
PEN = 'PEN',
PGK = 'PGK',
PHP = 'PHP',
PKR = 'PKR',
PLN = 'PLN',
PYG = 'PYG',
QAR = 'QAR',
RON = 'RON',
RSD = 'RSD',
RUB = 'RUB',
RWF = 'RWF',
SAR = 'SAR',
SBD = 'SBD',
SCR = 'SCR',
SDG = 'SDG',
SEK = 'SEK',
SGD = 'SGD',
SHP = 'SHP',
SLE = 'SLE',
SOS = 'SOS',
SRD = 'SRD',
SSP = 'SSP',
STN = 'STN',
SVC = 'SVC',
SYP = 'SYP',
SZL = 'SZL',
THB = 'THB',
TJS = 'TJS',
TMT = 'TMT',
TND = 'TND',
TOP = 'TOP',
TRY = 'TRY',
TTD = 'TTD',
TWD = 'TWD',
TZS = 'TZS',
UAH = 'UAH',
UGX = 'UGX',
USD = 'USD',
UYU = 'UYU',
UZS = 'UZS',
VES = 'VES',
VND = 'VND',
VUV = 'VUV',
WST = 'WST',
XCD = 'XCD',
XOF = 'XOF',
YER = 'YER',
ZAR = 'ZAR',
ZMW = 'ZMW',
ZWG = 'ZWG',
}
@@ -0,0 +1,160 @@
import { type CurrencyCode } from "@/constants/CurrencyCode";
export const CURRENCY_CODE_LABELS: Record<
CurrencyCode,
{ label: string }
> = {
AED: { label: 'UAE dirham' },
AFN: { label: 'Afghan afghani' },
ALL: { label: 'Albanian lek' },
AMD: { label: 'Armenian dram' },
ANG: { label: 'Netherlands Antillean guilder' },
AOA: { label: 'Angolan kwanza' },
ARS: { label: 'Argentine peso' },
AUD: { label: 'Australian dollar' },
AWG: { label: 'Aruban florin' },
AZN: { label: 'Azerbaijani manat' },
BAM: { label: 'Bosnia and Herzegovina mark' },
BBD: { label: 'Barbados dollar' },
BDT: { label: 'Bangladeshi taka' },
BGN: { label: 'Bulgarian lev' },
BHD: { label: 'Bahraini dinar' },
BIF: { label: 'Burundian franc' },
BMD: { label: 'Bermudian dollar' },
BND: { label: 'Brunei dollar' },
BOB: { label: 'Boliviano' },
BRL: { label: 'Brazilian real' },
BSD: { label: 'Bahamian dollar' },
BTN: { label: 'Bhutanese ngultrum' },
BWP: { label: 'Botswana pula' },
BYN: { label: 'Belarusian ruble' },
BZD: { label: 'Belize dollar' },
CAD: { label: 'Canadian dollar' },
CDF: { label: 'Congolese franc' },
CHF: { label: 'Swiss franc' },
CLP: { label: 'Chilean peso' },
CNY: { label: 'Chinese yuan' },
COP: { label: 'Colombian peso' },
CRC: { label: 'Costa Rican colon' },
CUP: { label: 'Cuban peso' },
CVE: { label: 'Cape Verdean escudo' },
CZK: { label: 'Czech koruna' },
DJF: { label: 'Djiboutian franc' },
DKK: { label: 'Danish krone' },
DOP: { label: 'Dominican peso' },
DZD: { label: 'Algerian Dinar' },
EGP: { label: 'Egyptian pound' },
ERN: { label: 'Eritrean nakfa' },
ETB: { label: 'Ethiopian birr' },
EUR: { label: 'Euro' },
FJD: { label: 'Fiji dollar' },
FKP: { label: 'Falkland Islands pound' },
GBP: { label: 'British pound' },
GEL: { label: 'Georgian lari' },
GHS: { label: 'Ghanaian cedi' },
GIP: { label: 'Gibraltar pound' },
GMD: { label: 'Gambian dalasi' },
GNF: { label: 'Guinean franc' },
GTQ: { label: 'Guatemalan quetzal' },
GYD: { label: 'Guyanese dollar' },
HKD: { label: 'Hong Kong dollar' },
HNL: { label: 'Honduran lempira' },
HTG: { label: 'Haitian gourde' },
HUF: { label: 'Hungarian forint' },
IDR: { label: 'Indonesian rupiah' },
ILS: { label: 'Israeli shekel' },
INR: { label: 'Indian rupee' },
IQD: { label: 'Iraqi dinar' },
IRR: { label: 'Iranian rial' },
ISK: { label: 'Icelandic króna' },
JMD: { label: 'Jamaican dollar' },
JOD: { label: 'Jordanian dinar' },
JPY: { label: 'Japanese yen' },
KES: { label: 'Kenyan shilling' },
KGS: { label: 'Kyrgyzstani som' },
KHR: { label: 'Cambodian riel' },
KMF: { label: 'Comoro franc' },
KPW: { label: 'North Korean won' },
KRW: { label: 'South Korean won' },
KWD: { label: 'Kuwaiti dinar' },
KYD: { label: 'Cayman Islands dollar' },
KZT: { label: 'Kazakhstani tenge' },
LAK: { label: 'Lao kip' },
LBP: { label: 'Lebanese pound' },
LKR: { label: 'Sri Lankan rupee' },
LRD: { label: 'Liberian dollar' },
LSL: { label: 'Lesotho loti' },
LYD: { label: 'Libyan dinar' },
MAD: { label: 'Moroccan dirham' },
MDL: { label: 'Moldovan leu' },
MGA: { label: 'Malagasy ariary' },
MKD: { label: 'Macedonian denar' },
MMK: { label: 'Myanmar kyat' },
MNT: { label: 'Mongolian tögrög' },
MOP: { label: 'Macanese pataca' },
MRU: { label: 'Mauritanian ouguiya' },
MUR: { label: 'Mauritian rupee' },
MVR: { label: 'Maldivian rufiyaa' },
MWK: { label: 'Malawian kwacha' },
MXN: { label: 'Mexican peso' },
MYR: { label: 'Malaysian ringgit' },
MZN: { label: 'Mozambican metical' },
NAD: { label: 'Namibian dollar' },
NGN: { label: 'Nigerian naira' },
NIO: { label: 'Nicaraguan córdoba' },
NOK: { label: 'Norwegian krone' },
NPR: { label: 'Nepalese rupee' },
NZD: { label: 'New Zealand dollar' },
OMR: { label: 'Omani rial' },
PAB: { label: 'Panamanian balboa' },
PEN: { label: 'Peruvian sol' },
PGK: { label: 'Papua New Guinean kina' },
PHP: { label: 'Philippine peso' },
PKR: { label: 'Pakistani rupee' },
PLN: { label: 'Polish złoty' },
PYG: { label: 'Paraguayan guaraní' },
QAR: { label: 'Qatari riyal' },
RON: { label: 'Romanian leu' },
RSD: { label: 'Serbian dinar' },
RUB: { label: 'Russian ruble' },
RWF: { label: 'Rwandan franc' },
SAR: { label: 'Saudi riyal' },
SBD: { label: 'Solomon Islands dollar' },
SCR: { label: 'Seychelles rupee' },
SDG: { label: 'Sudanese pound' },
SEK: { label: 'Swedish krona' },
SGD: { label: 'Singapore dollar' },
SHP: { label: 'Saint Helena pound' },
SLE: { label: 'Sierra Leonean leone' },
SOS: { label: 'Somalian shilling' },
SRD: { label: 'Surinamese dollar' },
SSP: { label: 'South Sudanese pound' },
STN: { label: 'São Tomé and Príncipe dobra' },
SVC: { label: 'Salvadoran colón' },
SYP: { label: 'Syrian pound' },
SZL: { label: 'Swazi lilangeni' },
THB: { label: 'Thai Baht' },
TJS: { label: 'Tajikistani somoni' },
TMT: { label: 'Turkmenistan manat' },
TND: { label: 'Tunisian dinar' },
TOP: { label: 'Tongan paʻanga' },
TRY: { label: 'Turkish lira' },
TTD: { label: 'Trinidad and Tobago dollar' },
TWD: { label: 'Taiwanese dollar' },
TZS: { label: 'Tanzanian shilling' },
UAH: { label: 'Ukrainian hryvnia' },
UGX: { label: 'Ugandan shilling' },
USD: { label: 'United States dollar' },
UYU: { label: 'Uruguayan peso' },
UZS: { label: 'Uzbekistani sum' },
VES: { label: 'Venezuelan bolívar' },
VND: { label: 'Vietnamese đồng' },
VUV: { label: 'Vanuatu vatu' },
WST: { label: 'Samoan tala' },
XOF: { label: 'West African CFA franc' },
XCD: { label: 'East Caribbean dollar' },
YER: { label: 'Yemeni rial' },
ZAR: { label: 'South African rand' },
ZMW: { label: 'Zambian kwacha' },
ZWG: { label: 'Zimbabwe Gold' },
};
@@ -8,6 +8,8 @@
*/
export { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from './CompositeFieldTypeSubFieldsNames';
export { CurrencyCode } from './CurrencyCode';
export { CURRENCY_CODE_LABELS } from './CurrencyCodeLabels';
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
@@ -1,8 +1,10 @@
import { type FieldMetadataType } from "@/types/FieldMetadataType";
import { type PartialFieldMetadataItemOption } from "@/types/PartialFieldMetadataOption";
export type PartialFieldMetadataItem = {
id: string;
name: string;
type: FieldMetadataType;
label: string;
options?: PartialFieldMetadataItemOption[] | null;
}
@@ -0,0 +1,7 @@
export type PartialFieldMetadataItemOption = {
id: string;
label: string;
position: number;
value: string;
};
@@ -35,6 +35,7 @@ export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId';
export type { ObjectsPermissionsByRoleIdDeprecated } from './ObjectsPermissionsByRoleIdDeprecated';
export type { ObjectsPermissionsDeprecated } from './ObjectsPermissionsDeprecated';
export type { PartialFieldMetadataItem } from './PartialFieldMetadataItem';
export type { PartialFieldMetadataItemOption } from './PartialFieldMetadataOption';
export { RecordFilterGroupLogicalOperator } from './RecordFilterGroupLogicalOperator';
export type { RecordFilterValueDependencies } from './RecordFilterValueDependencies';
export type {
@@ -0,0 +1,19 @@
import { isNonEmptyArray } from "@/utils/array/isNonEmptyArray";
describe('isNonEmptyArray', () => {
it('should return true for a non empty array', () => {
expect(isNonEmptyArray([1])).toBe(true);
});
it('should return false for an empty array', () => {
expect(isNonEmptyArray([])).toBe(false);
});
it('should return false for a null value', () => {
expect(isNonEmptyArray(null)).toBe(false);
});
it('should return false for an undefined value', () => {
expect(isNonEmptyArray(undefined)).toBe(false);
});
});
@@ -0,0 +1,15 @@
import { isNumber } from '@sniptt/guards';
export const isNonEmptyArray = <T>(
probableArray: T[] | readonly T[] | undefined | null,
): probableArray is NonNullable<T[]> => {
if (
Array.isArray(probableArray) &&
isNumber(probableArray.length) &&
probableArray.length > 0
) {
return true;
}
return false;
};
@@ -6,6 +6,7 @@ export * from './computeEmptyGqlOperationFilterForEmails';
export * from './computeEmptyGqlOperationFilterForLinks';
export * from './computeRecordGqlOperationFilter';
export * from './isEmptinessOperand';
export * from './turnAnyFieldFilterIntoRecordGqlFilter';
export * from './turnRecordFilterGroupIntoGqlOperationFilter';
export * from './turnRecordFilterIntoGqlOperationFilter';
export * from './utils/combineFilters';
@@ -0,0 +1,238 @@
import { CURRENCY_CODE_LABELS } from '@/constants';
import { type CurrencyCode } from '@/constants/CurrencyCode';
import { FieldMetadataType, ViewFilterOperand, type PartialFieldMetadataItem, type RecordGqlOperationFilter } from '@/types';
import { filterSelectOptionsOfFieldMetadataItem, type RecordFilter } from '@/utils';
import { isNonEmptyArray } from '@/utils/array/isNonEmptyArray';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter';
import { createAnyFieldRecordFilterBaseProperties } from '@/utils/filter/utils/createAnyFieldRecordFilterBaseProperties';
import { isDefined } from '@/utils/validation';
import { isNonEmptyString } from '@sniptt/guards';
import { z } from 'zod';
const currencies: { value: CurrencyCode; label: string }[] = Object.entries(
CURRENCY_CODE_LABELS,
).map(([key, { label }]) => ({
value: key as CurrencyCode,
label: `${label} (${key})`,
}));
export const turnAnyFieldFilterIntoRecordGqlFilter = ({
filterValue,
fields,
}: {
filterValue: string;
fields: PartialFieldMetadataItem[];
}) => {
const anyFieldRecordFilters: RecordFilter[] = [];
const isFilterValueANumber = z.coerce.number().safeParse(filterValue).success;
for (const field of fields) {
switch (field.type) {
case FieldMetadataType.TEXT: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'TEXT',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.ADDRESS: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'ADDRESS',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.LINKS: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'LINKS',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.FULL_NAME: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'FULL_NAME',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.ARRAY: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'ARRAY',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.EMAILS: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'EMAILS',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.PHONES: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'PHONES',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.NUMBER: {
if (isFilterValueANumber) {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.IS,
type: 'NUMBER',
} satisfies RecordFilter);
}
break;
}
case FieldMetadataType.CURRENCY: {
if (isFilterValueANumber) {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.IS,
type: 'CURRENCY',
} satisfies RecordFilter);
}
if (isNonEmptyString(filterValue)) {
const foundCorrespondingCurrencies = currencies.filter(
(currency) =>
currency.label.includes(filterValue) ||
currency.value.includes(filterValue),
);
if (isNonEmptyArray(foundCorrespondingCurrencies)) {
const arrayOfCurrenciesStringified = JSON.stringify(
foundCorrespondingCurrencies.map((currency) => currency.value),
);
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue: arrayOfCurrenciesStringified,
fieldMetadataItem: field,
}),
operand: ViewFilterOperand.IS,
type: 'CURRENCY',
subFieldName: 'currencyCode',
} satisfies RecordFilter);
}
}
break;
}
case FieldMetadataType.SELECT: {
if (isNonEmptyString(filterValue)) {
const { foundCorrespondingSelectOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem: field,
filterValue,
});
if (isNonEmptyArray(foundCorrespondingSelectOptions)) {
const arrayOfSelectValues = JSON.stringify(
foundCorrespondingSelectOptions.map(
(selectOption) => selectOption.value,
),
);
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
fieldMetadataItem: field,
filterValue: arrayOfSelectValues,
}),
operand: ViewFilterOperand.IS,
type: 'SELECT',
} satisfies RecordFilter);
}
}
break;
}
case FieldMetadataType.MULTI_SELECT: {
if (isNonEmptyString(filterValue)) {
const { foundCorrespondingSelectOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem: field,
filterValue,
});
if (isNonEmptyArray(foundCorrespondingSelectOptions)) {
const arrayOfSelectValues = JSON.stringify(
foundCorrespondingSelectOptions.map(
(selectOption) => selectOption.value,
),
);
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
fieldMetadataItem: field,
filterValue: arrayOfSelectValues,
}),
operand: ViewFilterOperand.CONTAINS,
type: 'MULTI_SELECT',
} satisfies RecordFilter);
}
}
break;
}
}
}
const baseRecordGqlOperationFilters = anyFieldRecordFilters
.map((recordFilter) =>
turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies: {},
fieldMetadataItems: fields,
recordFilter,
}),
)
.filter(isDefined);
const recordGqlOperationFilter: RecordGqlOperationFilter = {
or: baseRecordGqlOperationFilters,
};
if (baseRecordGqlOperationFilters.length === 0) {
return { recordGqlOperationFilter: {} };
}
return {
recordGqlOperationFilter,
};
};
@@ -0,0 +1,557 @@
import { FieldMetadataType, type PartialFieldMetadataItem } from "@/types";
import { turnAnyFieldFilterIntoRecordGqlFilter } from "@/utils/filter/turnAnyFieldFilterIntoRecordGqlFilter";
import { filterSelectOptionsOfFieldMetadataItem } from "@/utils/filter/utils/filterSelectOptionsOfFieldMetadataItem";
const baseFieldMetadataItem: PartialFieldMetadataItem = {
id: 'base-field-metadata-item-id',
label: 'Test',
name: 'test',
type: FieldMetadataType.TEXT,
};
const textFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'text-field-id',
name: 'textField',
};
const addressFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'address-field-id',
type: FieldMetadataType.ADDRESS,
name: 'addressField',
};
const linksFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'links-field-id',
type: FieldMetadataType.LINKS,
name: 'linksField',
};
const fullNameFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'full-name-field-id',
type: FieldMetadataType.FULL_NAME,
name: 'fullNameField',
};
const arrayFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'array-field-id',
type: FieldMetadataType.ARRAY,
name: 'arrayField',
};
const emailsFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'emails-field-id',
type: FieldMetadataType.EMAILS,
name: 'emailsField',
};
const phonesFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'phones-field-id',
type: FieldMetadataType.PHONES,
name: 'phonesField',
};
const numberFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'number-field-id',
type: FieldMetadataType.NUMBER,
name: 'numberField',
};
const currencyFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'currency-field-id',
type: FieldMetadataType.CURRENCY,
name: 'currencyField',
};
const selectFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'select-field-id',
type: FieldMetadataType.SELECT,
name: 'selectField',
options: [
{
id: '1',
label: 'blue',
position: 1,
value: 'BLUE',
},
{
id: '2',
label: 'red',
position: 2,
value: 'RED',
},
],
};
const multiSelectFieldMetadataItem: PartialFieldMetadataItem = {
...baseFieldMetadataItem,
id: 'multi-select-field-id',
type: FieldMetadataType.MULTI_SELECT,
name: 'multiSelect',
options: [
{
id: '1',
label: 'blue',
position: 1,
value: 'BLUE',
},
{
id: '2',
label: 'red',
position: 2,
value: 'RED',
},
],
};
const mockObjectMetadataItem = {
id: 'mock-object-metadata-item',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
indexMetadatas: [],
isActive: true,
isCustom: true,
isLabelSyncedWithName: true,
isRemote: false,
isSearchable: true,
isSystem: false,
isUIReadOnly: false,
labelIdentifierFieldMetadataId: 'mock-id',
labelPlural: 'Tests',
labelSingular: 'Test',
nameSingular: 'test',
namePlural: 'tests',
fields: [],
readableFields: [],
updatableFields: [],
};
const mockObjectMetadataItemWithAllFields = {
...mockObjectMetadataItem,
fields: [
textFieldMetadataItem,
addressFieldMetadataItem,
linksFieldMetadataItem,
fullNameFieldMetadataItem,
arrayFieldMetadataItem,
emailsFieldMetadataItem,
phonesFieldMetadataItem,
numberFieldMetadataItem,
currencyFieldMetadataItem,
selectFieldMetadataItem,
multiSelectFieldMetadataItem,
],
};
describe('turnAnyFieldFilterIntoRecordGqlFilter', () => {
describe('TEXT field type', () => {
it('should generate correct filter for text field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [textFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[textFieldMetadataItem.name]: {
ilike: `%${filterValue}%`,
},
});
});
});
describe('ADDRESS field type', () => {
it('should generate correct filter for address field', () => {
const filterValue = 'New York';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [addressFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[addressFieldMetadataItem.name]: {
addressStreet1: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressStreet2: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressCity: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressState: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressCountry: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressPostcode: {
ilike: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('LINKS field type', () => {
it('should generate correct filter for links field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [linksFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[linksFieldMetadataItem.name]: {
primaryLinkUrl: {
ilike: `%${filterValue}%`,
},
},
},
{
[linksFieldMetadataItem.name]: {
primaryLinkLabel: {
ilike: `%${filterValue}%`,
},
},
},
{
[linksFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('FULL_NAME field type', () => {
it('should generate correct filter for full name field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [fullNameFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[fullNameFieldMetadataItem.name]: {
firstName: {
ilike: `%${filterValue}%`,
},
},
},
{
[fullNameFieldMetadataItem.name]: {
lastName: {
ilike: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('ARRAY field type', () => {
it('should generate correct filter for array field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [arrayFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[arrayFieldMetadataItem.name]: {
containsIlike: `%${filterValue}%`,
},
});
});
});
describe('EMAILS field type', () => {
it('should generate correct filter for emails field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [emailsFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[emailsFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${filterValue}%`,
},
},
},
{
[emailsFieldMetadataItem.name]: {
additionalEmails: {
like: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('PHONES field type', () => {
it('should generate correct filter for phones field', () => {
const filterValue = '123';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [phonesFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[phonesFieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
},
},
{
[phonesFieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
},
},
{
[phonesFieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('NUMBER field type', () => {
it('should generate correct filter for number field with numeric value', () => {
const filterValue = '123.1';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [numberFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[numberFieldMetadataItem.name]: {
eq: 123.1,
},
});
});
it('should not generate filter for number field with non-numeric value', () => {
const filterValue = 'not a number';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [numberFieldMetadataItem],
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
describe('CURRENCY field type', () => {
it('should generate correct filter for currency field with numeric value', () => {
const filterValue = '123';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [currencyFieldMetadataItem],
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[currencyFieldMetadataItem.name]: {
amountMicros: {
eq: 123000000,
},
},
});
});
it('should generate correct filter for currency field with currency code', () => {
const filterValue = 'USD';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [currencyFieldMetadataItem],
});
expect(
(result.recordGqlOperationFilter.or as any)?.[0][
currencyFieldMetadataItem.name
].currencyCode.in.includes(filterValue),
).toBe(true);
});
});
describe('SELECT field type', () => {
it('should generate correct filter for select field with matching option', () => {
const filterValue = 'r';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [selectFieldMetadataItem],
});
const { foundCorrespondingSelectOptions: expectedOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem: selectFieldMetadataItem,
filterValue,
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[selectFieldMetadataItem.name]: {
in: expectedOptions?.map((option) => option.value),
},
});
});
it('should not generate filter for select field with non-matching value', () => {
const filterValue = 'not-found';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [selectFieldMetadataItem],
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
describe('MULTI_SELECT field type', () => {
it('should generate correct filter for multi-select field with matching option', () => {
const filterValue = 'r';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [multiSelectFieldMetadataItem],
});
const { foundCorrespondingSelectOptions: expectedOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem: multiSelectFieldMetadataItem,
filterValue,
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[multiSelectFieldMetadataItem.name]: {
containsAny: expectedOptions?.map((option) => option.value),
},
});
});
it('should not generate filter for multi-select field with non-matching value', () => {
const filterValue = 'not-found';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: [multiSelectFieldMetadataItem],
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
describe('combined field filters', () => {
it('should generate OR filter combining all matching field types', () => {
const filterValue = 'a';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: mockObjectMetadataItemWithAllFields.fields,
});
expect(result.recordGqlOperationFilter).toHaveProperty('or');
expect(Array.isArray(result.recordGqlOperationFilter.or)).toBe(true);
expect(
(result.recordGqlOperationFilter.or as any[])?.length,
).toBeGreaterThan(0);
});
it('should handle empty filter value', () => {
const filterValue = '';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
fields: mockObjectMetadataItemWithAllFields.fields,
});
expect(result.recordGqlOperationFilter).toEqual({});
});
it('should handle object with no fields', () => {
const emptyObjectMetadata = {
...mockObjectMetadataItem,
fields: [],
};
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue: 'test',
fields: emptyObjectMetadata.fields,
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
});
@@ -0,0 +1,20 @@
import { type PartialFieldMetadataItem } from '@/types';
import { type RecordFilter } from '@/utils/filter';
import { v4 } from 'uuid';
export const createAnyFieldRecordFilterBaseProperties = ({
filterValue,
fieldMetadataItem,
}: {
filterValue: string;
fieldMetadataItem: PartialFieldMetadataItem;
}): Pick<
RecordFilter,
'id' | 'value' | 'fieldMetadataId'
> => {
return {
id: v4(),
value: filterValue,
fieldMetadataId: fieldMetadataItem.id,
};
};
@@ -0,0 +1,25 @@
import { type PartialFieldMetadataItem } from "@/types";
export const filterSelectOptionsOfFieldMetadataItem = ({
fieldMetadataItem,
filterValue,
}: {
fieldMetadataItem: PartialFieldMetadataItem;
filterValue: string;
}) => {
const selectOptions = fieldMetadataItem.options;
const foundCorrespondingSelectOptions = selectOptions?.filter(
(selectOption) =>
selectOption.value
.toLocaleLowerCase()
.includes(filterValue.toLocaleLowerCase()) ||
selectOption.label
.toLocaleLowerCase()
.includes(filterValue.toLocaleLowerCase()),
);
return {
foundCorrespondingSelectOptions,
};
};
@@ -12,6 +12,7 @@ export { filterOutByProperty } from './array/filterOutByProperty';
export { findById } from './array/findById';
export { findByProperty } from './array/findByProperty';
export { findOrThrow } from './array/findOrThrow';
export { isNonEmptyArray } from './array/isNonEmptyArray';
export { sumByProperty } from './array/sumByProperty';
export { assertUnreachable } from './assertUnreachable';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
@@ -27,6 +28,7 @@ export { computeEmptyGqlOperationFilterForEmails } from './filter/computeEmptyGq
export { computeEmptyGqlOperationFilterForLinks } from './filter/computeEmptyGqlOperationFilterForLinks';
export { computeRecordGqlOperationFilter } from './filter/computeRecordGqlOperationFilter';
export { isEmptinessOperand } from './filter/isEmptinessOperand';
export { turnAnyFieldFilterIntoRecordGqlFilter } from './filter/turnAnyFieldFilterIntoRecordGqlFilter';
export type {
RecordFilter,
RecordFilterGroup,
@@ -35,11 +37,13 @@ export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecor
export { turnRecordFilterIntoRecordGqlOperationFilter } from './filter/turnRecordFilterIntoGqlOperationFilter';
export { combineFilters } from './filter/utils/combineFilters';
export { convertViewFilterValueToString } from './filter/utils/convertViewFilterValueToString';
export { createAnyFieldRecordFilterBaseProperties } from './filter/utils/createAnyFieldRecordFilterBaseProperties';
export {
convertGreaterThanOrEqualRatingToArrayOfRatingValues,
convertLessThanOrEqualRatingToArrayOfRatingValues,
convertRatingToRatingValue,
} from './filter/utils/fieldRatingConvertors';
export { filterSelectOptionsOfFieldMetadataItem } from './filter/utils/filterSelectOptionsOfFieldMetadataItem';
export { generateILikeFiltersForCompositeFields } from './filter/utils/generateILikeFiltersForCompositeFields';
export { getEmptyRecordGqlOperationFilter } from './filter/utils/getEmptyRecordGqlOperationFilter';
export { getFilterTypeFromFieldType } from './filter/utils/getFilterTypeFromFieldType';