[GroupBy] Add views filters to groupBy query (#14762)

Closes https://github.com/twentyhq/core-team-issues/issues/1560

If viewId is defined in a groupBy query, we want to apply all filters of
the view to the query.
This required to move a lot of code from twenty-front to twenty-shared
to convert the filters as stored in the db into graphql filters,
applying the right combinations between filters etc., which was
previously only done in the FE.

This PR does not handle any field filters, it will be done in a later pr
This commit is contained in:
Marie
2025-09-30 09:58:55 +02:00
committed by GitHub
parent 80fa4c6c26
commit da3c3d2b9d
174 changed files with 1192 additions and 483 deletions
@@ -0,0 +1,9 @@
// This class is used to group different error messages under the same code for sentry.
export class CustomError extends Error {
public code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
@@ -0,0 +1 @@
export { CustomError } from './CustomError';
@@ -0,0 +1,29 @@
import { type FieldMetadataType, type FilterableAndTSVectorFieldType, type ViewFilterOperand } from '@/types';
import { isEmptinessOperand } from '@/utils/filter/isEmptinessOperand';
import { getFilterTypeFromFieldType } from '@/utils/filter/utils/getFilterTypeFromFieldType';
export const checkIfShouldComputeEmptinessFilter = ({
recordFilterOperand,
correspondingFieldMetadataItem,
}: {
recordFilterOperand: ViewFilterOperand;
correspondingFieldMetadataItem: {type: FieldMetadataType};
}) => {
const isAnEmptinessOperand = isEmptinessOperand(recordFilterOperand);
if (!isAnEmptinessOperand) {
return false;
}
const filterTypesThatHaveNoEmptinessOperand: FilterableAndTSVectorFieldType[] =
['BOOLEAN', 'TS_VECTOR'];
const filterType = getFilterTypeFromFieldType(
correspondingFieldMetadataItem.type,
);
const filterHasEmptinessOperands =
!filterTypesThatHaveNoEmptinessOperand.includes(filterType);
return filterHasEmptinessOperands === true;
};
@@ -0,0 +1,27 @@
import { ViewFilterOperand as RecordFilterOperand } from '@/types';
import { isDefined } from '@/utils';
import { isEmptinessOperand, type RecordFilter } from '@/utils/filter';
type CheckIfShouldSkipFilteringParams = {
recordFilter: Pick<RecordFilter, 'operand' | 'value'>;
};
export const checkIfShouldSkipFiltering = ({
recordFilter,
}: CheckIfShouldSkipFilteringParams) => {
const isAnEmptinessOperand = isEmptinessOperand(recordFilter.operand);
const isDateOperandWithoutValue = [
RecordFilterOperand.IsInPast,
RecordFilterOperand.IsInFuture,
RecordFilterOperand.IsToday,
].includes(recordFilter.operand);
const isFilterValueEmpty =
!isDefined(recordFilter.value) || recordFilter.value === '';
const shouldSkipFiltering =
!isAnEmptinessOperand && !isDateOperandWithoutValue && isFilterValueEmpty;
return shouldSkipFiltering;
};
@@ -0,0 +1,158 @@
import {
type CompositeFieldSubFieldName,
type EmailsFilter,
type PartialFieldMetadataItem,
ViewFilterOperand as RecordFilterOperand,
type RecordGqlOperationFilter,
} from '@/types';
import { CustomError } from '@/utils/errors';
import { type RecordFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { isNonEmptyString } from '@sniptt/guards';
export const computeGqlOperationFilterForEmails = ({
recordFilter,
correspondingFieldMetadataItem,
subFieldName,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name' | 'type'>;
subFieldName: CompositeFieldSubFieldName | null | undefined;
}): RecordGqlOperationFilter => {
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryEmail': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
not: {
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
}
case 'additionalEmails': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
is: 'NULL',
},
} satisfies EmailsFilter,
},
],
};
default:
throw new CustomError(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
'UNKNOWN_OPERAND_FOR_FILTER',
);
}
}
default: {
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
},
{
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
is: 'NULL',
},
} satisfies EmailsFilter,
},
],
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
};
@@ -0,0 +1,168 @@
import { ViewFilterOperand as RecordFilterOperand, type CompositeFieldSubFieldName, type LinksFilter, type PartialFieldMetadataItem } from '@/types';
import { CustomError } from '@/utils/errors';
import { type RecordFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { isNonEmptyString } from '@sniptt/guards';
export const computeGqlOperationFilterForLinks = ({
recordFilter,
correspondingFieldMetadataItem,
subFieldName,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name' | 'type'>
subFieldName: CompositeFieldSubFieldName | null | undefined;
}) => {
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryLinkLabel':
case 'primaryLinkUrl': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
};
default:
throw new CustomError(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
'UNKNOWN_OPERAND_FOR_FILTER',
);
}
}
case 'secondaryLinks': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
is: 'NULL',
},
} satisfies LinksFilter,
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
}
default: {
throw new Error( // TODO
`Unknown subfield name ${subFieldName}`,
// 'UNKNOWN_SUBFIELD_NAME',
);
}
}
}
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
not: {
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
is: 'NULL',
},
} satisfies LinksFilter,
},
],
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
};
@@ -0,0 +1,96 @@
import {
type EmailsFilter,
type PartialFieldMetadataItem,
type RecordGqlOperationFilter,
} from '@/types';
import { CustomError } from '@/utils/errors';
import { type RecordFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { isNonEmptyString } from '@sniptt/guards';
export const computeEmptyGqlOperationFilterForEmails = ({
recordFilter,
correspondingFieldMetadataItem,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name' | 'type'>;
}): RecordGqlOperationFilter => {
const subFieldName = recordFilter.subFieldName;
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryEmail': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { eq: '' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { is: 'NULL' },
} satisfies EmailsFilter,
},
],
};
}
case 'additionalEmails': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { is: 'NULL' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { like: '[]' },
} satisfies EmailsFilter,
},
],
};
}
default: {
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}
return {
and: [
{
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { eq: '' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { is: 'NULL' },
} satisfies EmailsFilter,
},
],
},
{
or: [
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { is: 'NULL' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { like: '[]' },
} satisfies EmailsFilter,
},
],
},
],
};
};
@@ -0,0 +1,126 @@
import {
type LinksFilter,
type PartialFieldMetadataItem,
type RecordGqlOperationFilter,
} from '@/types';
import { CustomError } from '@/utils/errors';
import { type RecordFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { isNonEmptyString } from '@sniptt/guards';
export const computeEmptyGqlOperationFilterForLinks = ({
recordFilter,
correspondingFieldMetadataItem,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<PartialFieldMetadataItem, 'name'>;
}): RecordGqlOperationFilter => {
const subFieldName = recordFilter.subFieldName;
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryLinkLabel': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { is: 'NULL' },
} satisfies LinksFilter,
},
],
};
}
case 'primaryLinkUrl': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { is: 'NULL' },
} satisfies LinksFilter,
},
],
};
}
case 'secondaryLinks': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { is: 'NULL' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { like: '[]' },
} satisfies LinksFilter,
},
],
};
}
default: {
throw new CustomError(
`Unknown subfield name ${subFieldName}`,
'UNKNOWN_SUBFIELD_NAME',
);
}
}
}
return {
and: [
{
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { is: 'NULL' },
} satisfies LinksFilter,
},
],
},
{
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { is: 'NULL' },
} satisfies LinksFilter,
},
],
},
{
or: [
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { is: 'NULL' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { like: '[]' },
} satisfies LinksFilter,
},
],
},
],
};
};
@@ -0,0 +1,70 @@
import {
type PartialFieldMetadataItem,
type RecordFilterValueDependencies,
type RecordGqlOperationFilter,
} from '@/types';
import {
isDefined,
turnRecordFilterGroupsIntoGqlOperationFilter,
turnRecordFilterIntoRecordGqlOperationFilter,
type RecordFilter,
type RecordFilterGroup,
} from '@/utils';
export const computeRecordGqlOperationFilter = ({
fields,
recordFilters,
recordFilterGroups,
filterValueDependencies,
}: {
recordFilters: RecordFilter[];
fields: PartialFieldMetadataItem[];
recordFilterGroups: RecordFilterGroup[];
filterValueDependencies: RecordFilterValueDependencies;
}): RecordGqlOperationFilter => {
const regularRecordGqlOperationFilter: RecordGqlOperationFilter[] =
recordFilters
.filter((filter) => !isDefined(filter.recordFilterGroupId))
.map((regularFilter) => {
return turnRecordFilterIntoRecordGqlOperationFilter({
recordFilter: regularFilter,
fieldMetadataItems: fields,
filterValueDependencies,
});
})
.filter(isDefined);
const outermostFilterGroupId = recordFilterGroups.find(
(recordFilterGroup) => !recordFilterGroup.parentRecordFilterGroupId,
)?.id;
const advancedRecordGqlOperationFilter =
turnRecordFilterGroupsIntoGqlOperationFilter({
filterValueDependencies,
filters: recordFilters,
fields,
recordFilterGroups,
currentRecordFilterGroupId: outermostFilterGroupId,
}
);
const recordGqlOperationFilters = [
...regularRecordGqlOperationFilter,
advancedRecordGqlOperationFilter,
].filter(isDefined);
if (recordGqlOperationFilters.length === 0) {
return {};
}
if (recordGqlOperationFilters.length === 1) {
return recordGqlOperationFilters[0];
}
const recordGqlOperationFilter = {
and: recordGqlOperationFilters,
};
return recordGqlOperationFilter;
};
@@ -0,0 +1,22 @@
export * from './checkIfShouldComputeEmptinessFilter';
export * from './checkIfShouldSkipFiltering';
export * from './compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForEmails';
export * from './compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForLinks';
export * from './computeEmptyGqlOperationFilterForEmails';
export * from './computeEmptyGqlOperationFilterForLinks';
export * from './computeRecordGqlOperationFilter';
export * from './isEmptinessOperand';
export * from './turnRecordFilterGroupIntoGqlOperationFilter';
export * from './turnRecordFilterIntoGqlOperationFilter';
export * from './utils/combineFilters';
export * from './utils/fieldRatingConvertors';
export * from './utils/generateILikeFiltersForCompositeFields';
export * from './utils/getEmptyRecordGqlOperationFilter';
export * from './utils/getFilterTypeFromFieldType';
export * from './utils/isExpectedSubFieldName';
export * from './utils/resolveDateViewFilterValue';
export * from './utils/validation-schemas/arrayOfStringsOrVariablesSchema';
export * from './utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
export * from './utils/validation-schemas/jsonRelationFilterValueSchema';
@@ -0,0 +1,7 @@
import { ViewFilterOperand as RecordFilterOperand } from '@/types';
export const isEmptinessOperand = (operand: RecordFilterOperand): boolean => {
return [RecordFilterOperand.IsEmpty, RecordFilterOperand.IsNotEmpty].includes(
operand,
);
};
@@ -0,0 +1,99 @@
import { type CompositeFieldSubFieldName, type FilterableAndTSVectorFieldType, type PartialFieldMetadataItem, RecordFilterGroupLogicalOperator, type RecordFilterValueDependencies, type RecordGqlOperationFilter, type ViewFilterOperand } from '@/types';
import { isDefined } from '@/utils';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter';
export type RecordFilter = {
id: string;
fieldMetadataId: string;
value: string;
type: FilterableAndTSVectorFieldType;
recordFilterGroupId?: string | null;
operand: ViewFilterOperand;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
};
export type RecordFilterGroup = {
id: string;
parentRecordFilterGroupId?: string | null;
logicalOperator: RecordFilterGroupLogicalOperator;
}
export const turnRecordFilterGroupsIntoGqlOperationFilter = ({
filterValueDependencies,
filters,
fields,
recordFilterGroups,
currentRecordFilterGroupId,
}: {
filterValueDependencies: RecordFilterValueDependencies,
filters: RecordFilter[],
fields: PartialFieldMetadataItem[],
recordFilterGroups: RecordFilterGroup[],
currentRecordFilterGroupId?: string,
}): RecordGqlOperationFilter | undefined => {
const currentRecordFilterGroup = recordFilterGroups.find(
(recordFilterGroup) => recordFilterGroup.id === currentRecordFilterGroupId,
);
if (!isDefined(currentRecordFilterGroup)) {
return;
}
const recordFiltersInGroup = filters.filter(
(filter) => filter.recordFilterGroupId === currentRecordFilterGroupId,
);
const groupRecordGqlOperationFilters = recordFiltersInGroup
.map((recordFilter) =>
turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: recordFilter,
fieldMetadataItems: fields,
}),
)
.filter(isDefined);
const subGroupRecordGqlOperationFilters = recordFilterGroups
.filter(
(recordFilterGroup) =>
recordFilterGroup.parentRecordFilterGroupId ===
currentRecordFilterGroupId,
)
.map((subRecordFilterGroup) =>
turnRecordFilterGroupsIntoGqlOperationFilter({
filterValueDependencies,
filters,
fields,
recordFilterGroups,
currentRecordFilterGroupId: subRecordFilterGroup.id,
}),
)
.filter(isDefined);
if (
currentRecordFilterGroup.logicalOperator ===
RecordFilterGroupLogicalOperator.AND
) {
return {
and: [
...groupRecordGqlOperationFilters,
...subGroupRecordGqlOperationFilters,
],
};
} else if (
currentRecordFilterGroup.logicalOperator ===
RecordFilterGroupLogicalOperator.OR
) {
return {
or: [
...groupRecordGqlOperationFilters,
...subGroupRecordGqlOperationFilters,
],
};
} else {
throw new Error(
`Unknown logical operator ${currentRecordFilterGroup.logicalOperator}`,
);
}
};
@@ -0,0 +1,91 @@
import { arrayOfStringsOrVariablesSchema } from "@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema";
describe('arrayOfStringsOrVariablesSchema', () => {
describe('Empty value handling', () => {
it('should return empty array for empty string', () => {
const result = arrayOfStringsOrVariablesSchema.safeParse('');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
});
describe('Variable syntax validation', () => {
it('should accept valid variable syntax', () => {
const validVariables = [
'{{variable}}',
'{{user.id}}',
'{{company.name}}',
];
validVariables.forEach((variable) => {
const result = arrayOfStringsOrVariablesSchema.safeParse(variable);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([variable]);
}
});
});
});
describe('JSON array handling', () => {
it('should accept valid JSON array of strings', () => {
const validArrays = [
JSON.stringify(['value1', 'value2']),
JSON.stringify(['{{variable1}}', '{{variable2}}']),
JSON.stringify(['value1', '{{variable2}}']),
];
validArrays.forEach((array) => {
const result = arrayOfStringsOrVariablesSchema.safeParse(array);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(JSON.parse(array));
}
});
});
it('should reject JSON array with non-string values', () => {
const invalidArrays = [
JSON.stringify([1, 2, 3]),
JSON.stringify([true, false]),
JSON.stringify([null]),
JSON.stringify([{}]),
JSON.stringify([[]]),
];
invalidArrays.forEach((array) => {
const result = arrayOfStringsOrVariablesSchema.safeParse(array);
expect(result.success).toBe(false);
});
});
});
describe('Edge cases', () => {
it('should handle whitespace in variable syntax', () => {
const result =
arrayOfStringsOrVariablesSchema.safeParse('{{ variable }}');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(['{{ variable }}']);
}
});
it('should handle nested variables in JSON array', () => {
const input = JSON.stringify(['{{outer.{{inner}}}}']);
const result = arrayOfStringsOrVariablesSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(['{{outer.{{inner}}}}']);
}
});
it('should handle empty array in JSON', () => {
const result = arrayOfStringsOrVariablesSchema.safeParse('[]');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
});
});
@@ -0,0 +1,169 @@
import { arrayOfUuidOrVariableSchema } from '../validation-schemas/arrayOfUuidsOrVariablesSchema';
describe('arrayOfUuidOrVariableSchema', () => {
describe('UUID validation', () => {
it('should accept valid UUIDs', () => {
const validUuids = [
'123e4567-e89b-12d3-a456-426614174000',
'550e8400-e29b-41d4-a716-446655440000',
];
validUuids.forEach((uuid) => {
// Test as single value
const singleResult = arrayOfUuidOrVariableSchema.safeParse(uuid);
expect(singleResult.success).toBe(true);
if (singleResult.success) {
expect(singleResult.data).toEqual([uuid]);
}
// Test as array
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([uuid]);
expect(arrayResult.success).toBe(true);
if (arrayResult.success) {
expect(arrayResult.data).toEqual([uuid]);
}
});
});
it('should return empty array for invalid UUIDs', () => {
const invalidUuids = [
'invalid-uuid',
'12345',
'550e8400e29b41d4a716446655440000',
'',
'123e4567-e89b-12d3-a456-42661417400-',
];
invalidUuids.forEach((uuid) => {
// Test as single value
const singleResult = arrayOfUuidOrVariableSchema.safeParse(uuid);
expect(singleResult.success).toBe(true);
if (singleResult.success) {
expect(singleResult.data).toEqual([]);
}
// Test as array
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([uuid]);
expect(arrayResult.success).toBe(true);
if (arrayResult.success) {
expect(arrayResult.data).toEqual([]);
}
});
});
});
describe('Variable syntax validation', () => {
it('should accept valid variable syntax', () => {
const validVariables = [
'{{variable}}',
'{{user.id}}',
'{{company.name}}',
];
validVariables.forEach((variable) => {
// Test as single value
const singleResult = arrayOfUuidOrVariableSchema.safeParse(variable);
expect(singleResult.success).toBe(true);
if (singleResult.success) {
expect(singleResult.data).toEqual([variable]);
}
// Test as array
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([variable]);
expect(arrayResult.success).toBe(true);
if (arrayResult.success) {
expect(arrayResult.data).toEqual([variable]);
}
});
});
it('should return empty array for invalid variable syntax', () => {
const invalidVariables = ['{{variable', 'variable}}', '{{}}', '{{', '}}'];
invalidVariables.forEach((variable) => {
// Test as single value
const singleResult = arrayOfUuidOrVariableSchema.safeParse(variable);
expect(singleResult.success).toBe(true);
if (singleResult.success) {
expect(singleResult.data).toEqual([]);
}
// Test as array
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([variable]);
expect(arrayResult.success).toBe(true);
if (arrayResult.success) {
expect(arrayResult.data).toEqual([]);
}
});
});
});
describe('Input type handling', () => {
it('should handle string input with valid JSON', () => {
const input = JSON.stringify(['123e4567-e89b-12d3-a456-426614174000']);
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(['123e4567-e89b-12d3-a456-426614174000']);
}
});
it('should handle string input with variables', () => {
const input = '{{variable}}';
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(['{{variable}}']);
}
});
it('should handle array input directly', () => {
const input = ['123e4567-e89b-12d3-a456-426614174000', '{{variable}}'];
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(input);
}
});
it('should handle single value input', () => {
const input = '20202020-0687-4c41-b707-ed1bfca972a7';
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([input]);
}
});
});
describe('Error handling', () => {
it('should return empty array for invalid JSON string', () => {
const input = 'invalid-json';
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
it('should return empty array for non-string, non-array input', () => {
const inputs = [null, undefined, 123, true, {}];
inputs.forEach((input) => {
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
});
it('should return empty array for array with invalid values', () => {
const input = ['invalid-uuid', 'not-a-variable'];
const result = arrayOfUuidOrVariableSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual([]);
}
});
});
});
@@ -0,0 +1,64 @@
import { generateILikeFiltersForCompositeFields } from "@/utils/filter/utils/generateILikeFiltersForCompositeFields";
describe('generateILikeFiltersForCompositeFields', () => {
it('should format composite filters for simple filter string', () => {
expect(
generateILikeFiltersForCompositeFields('john', 'baseField', [
'subField1',
'subField2',
]),
).toEqual([
{
baseField: {
subField1: {
ilike: '%john%',
},
},
},
{
baseField: {
subField2: {
ilike: '%john%',
},
},
},
]);
});
it('should format composite filters for complex filter string', () => {
expect(
generateILikeFiltersForCompositeFields('john doe', 'name', [
'firstName',
'lastName',
]),
).toEqual([
{
name: {
firstName: {
ilike: '%john%',
},
},
},
{
name: {
lastName: {
ilike: '%john%',
},
},
},
{
name: {
firstName: {
ilike: '%doe%',
},
},
},
{
name: {
lastName: {
ilike: '%doe%',
},
},
},
]);
});
});
@@ -0,0 +1,21 @@
import { type RecordGqlOperationFilter } from '@/types';
export const combineFilters = (
filters: RecordGqlOperationFilter[],
): RecordGqlOperationFilter => {
const nonEmptyFilters = filters.filter(
(filter) => Object.keys(filter).length > 0,
);
if (nonEmptyFilters.length === 0) {
return {};
}
if (nonEmptyFilters.length === 1) {
return nonEmptyFilters[0];
}
return {
and: nonEmptyFilters,
};
};
@@ -0,0 +1,5 @@
export const convertViewFilterValueToString = (value: any) => {
return typeof value === 'string'
? value
: JSON.stringify(value ?? '');
};
@@ -0,0 +1,19 @@
import { RATING_VALUES } from "@/constants";
import { type FieldRatingValue } from "@/types";
export const convertGreaterThanOrEqualRatingToArrayOfRatingValues = (
greaterThanValue: number,
) =>
RATING_VALUES.filter(
(ratingValue) => +ratingValue.split('_')[1] >= greaterThanValue,
);
export const convertLessThanOrEqualRatingToArrayOfRatingValues = (
lessThanValue: number,
) =>
RATING_VALUES.filter(
(ratingValue) => +ratingValue.split('_')[1] <= lessThanValue,
);
export const convertRatingToRatingValue = (rating: number) =>
`RATING_${rating}` as FieldRatingValue;
@@ -0,0 +1,48 @@
import { type RecordGqlOperationFilter } from '@/types';
export const generateILikeFiltersForCompositeFields = (
filterString: string,
baseFieldName: string,
subFields: string[],
emptyCheck = false,
) => {
if (emptyCheck) {
return subFields.map((subField) => {
return {
or: [
{
[baseFieldName]: {
[subField]: {
is: 'NULL',
},
},
},
{
[baseFieldName]: {
[subField]: {
ilike: '',
},
},
},
],
};
});
}
return filterString
.split(' ')
.reduce((previousValue: RecordGqlOperationFilter[], currentValue) => {
return [
...previousValue,
...subFields.map((subField) => {
return {
[baseFieldName]: {
[subField]: {
ilike: `%${currentValue}%`,
},
},
};
}),
];
}, []);
};
@@ -0,0 +1,407 @@
import {
ViewFilterOperand,
type ActorFilter,
type AddressFilter,
type ArrayFilter,
type CurrencyFilter,
type DateFilter,
type FloatFilter,
type MultiSelectFilter,
type PartialFieldMetadataItem,
type PhonesFilter,
type RatingFilter,
type RawJsonFilter,
type RecordGqlOperationFilter,
type RelationFilter,
type SelectFilter,
type StringFilter,
} from '@/types';
import { CustomError } from '@/utils/errors';
import { computeEmptyGqlOperationFilterForEmails } from '@/utils/filter/computeEmptyGqlOperationFilterForEmails';
import { computeEmptyGqlOperationFilterForLinks } from '@/utils/filter/computeEmptyGqlOperationFilterForLinks';
import { type RecordFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { generateILikeFiltersForCompositeFields } from '@/utils/filter/utils/generateILikeFiltersForCompositeFields';
import { getFilterTypeFromFieldType } from '@/utils/filter/utils/getFilterTypeFromFieldType';
import { isNonEmptyString } from '@sniptt/guards';
type GetEmptyRecordGqlOperationFilterParams = {
operand: ViewFilterOperand;
correspondingField: Pick<PartialFieldMetadataItem, 'id' | 'name' | 'type'>;
recordFilter: RecordFilter;
};
export const getEmptyRecordGqlOperationFilter = ({
operand,
correspondingField,
recordFilter,
}: GetEmptyRecordGqlOperationFilterParams) => {
let emptyRecordFilter: RecordGqlOperationFilter = {};
const compositeFieldName = recordFilter.subFieldName;
const isSubFieldFilter = isNonEmptyString(compositeFieldName);
const filterType = getFilterTypeFromFieldType(correspondingField.type);
switch (filterType) {
case 'TEXT':
emptyRecordFilter = {
or: [
{ [correspondingField.name]: { ilike: '' } as StringFilter },
{ [correspondingField.name]: { is: 'NULL' } as StringFilter },
],
};
break;
case 'PHONES': {
if (!isSubFieldFilter) {
emptyRecordFilter = {
and: [
{
or: [
{
[correspondingField.name]: {
primaryPhoneNumber: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
primaryPhoneNumber: { ilike: '' },
} as PhonesFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
primaryPhoneCallingCode: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
primaryPhoneCallingCode: { ilike: '' },
} as PhonesFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
additionalPhones: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
additionalPhones: { like: '[]' },
} as PhonesFilter,
},
],
},
],
};
} else {
switch (compositeFieldName) {
case 'primaryPhoneNumber':
case 'primaryPhoneCallingCode': {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
[compositeFieldName]: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
[compositeFieldName]: { ilike: '' },
} as PhonesFilter,
},
],
};
break;
}
case 'additionalPhones': {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
additionalPhones: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
additionalPhones: { like: '[]' },
} as PhonesFilter,
},
],
};
break;
}
default: {
throw new Error(
`Unsupported composite field name ${compositeFieldName} for filter type ${filterType}`,
);
}
}
}
break;
}
case 'CURRENCY':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
amountMicros: { is: 'NULL' },
} as CurrencyFilter,
},
],
};
break;
case 'FULL_NAME': {
if (!isSubFieldFilter) {
const fullNameFilters = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['firstName', 'lastName'],
true,
);
emptyRecordFilter = {
and: fullNameFilters,
};
} else {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
[compositeFieldName]: { ilike: '' },
},
},
{
[correspondingField.name]: {
[compositeFieldName]: { is: 'NULL' },
},
},
],
};
}
break;
}
case 'LINKS': {
emptyRecordFilter = computeEmptyGqlOperationFilterForLinks({
correspondingFieldMetadataItem: correspondingField,
recordFilter,
});
break;
}
case 'ADDRESS':
if (!isSubFieldFilter) {
emptyRecordFilter = {
and: [
{
or: [
{
[correspondingField.name]: {
addressStreet1: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressStreet1: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressStreet2: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressStreet2: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressCity: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCity: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressState: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressState: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressCountry: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCountry: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressPostcode: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressPostcode: { is: 'NULL' },
} as AddressFilter,
},
],
},
],
};
} else {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
[compositeFieldName]: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
[compositeFieldName]: { is: 'NULL' },
} as AddressFilter,
},
],
};
}
break;
case 'NUMBER':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as FloatFilter,
};
break;
case 'RATING':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as RatingFilter,
};
break;
case 'DATE':
case 'DATE_TIME':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as DateFilter,
};
break;
case 'SELECT':
emptyRecordFilter = {
[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':
emptyRecordFilter = {
[correspondingField.name + 'Id']: { is: 'NULL' } as RelationFilter,
};
break;
case 'ACTOR':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
name: { ilike: '' },
} as ActorFilter,
},
{
[correspondingField.name]: {
name: { is: 'NULL' },
} as ActorFilter,
},
],
};
break;
case 'ARRAY':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: { is: 'NULL' } as ArrayFilter,
},
{
[correspondingField.name]: { isEmptyArray: true } as ArrayFilter,
},
],
};
break;
case 'RAW_JSON':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: { is: 'NULL' } as RawJsonFilter,
},
],
};
break;
case 'EMAILS':
emptyRecordFilter = computeEmptyGqlOperationFilterForEmails({
correspondingFieldMetadataItem: correspondingField,
recordFilter,
});
break;
default:
throw new CustomError(
`Unsupported empty filter type ${filterType}`,
'UNSUPPORTED_EMPTY_FILTER_TYPE',
);
}
switch (operand) {
case ViewFilterOperand.IsEmpty:
return emptyRecordFilter;
case ViewFilterOperand.IsNotEmpty:
return {
not: emptyRecordFilter,
};
default:
throw new CustomError(
`Unknown operand ${operand} for ${filterType} filter`,
'UNKNOWN_OPERAND_FOR_FILTER',
);
}
};
@@ -0,0 +1,49 @@
import { FieldMetadataType, type FilterableAndTSVectorFieldType } from "@/types";
export const getFilterTypeFromFieldType = (
fieldType: FieldMetadataType,
): FilterableAndTSVectorFieldType => {
switch (fieldType) {
case FieldMetadataType.DATE_TIME:
return 'DATE_TIME';
case FieldMetadataType.DATE:
return 'DATE';
case FieldMetadataType.LINKS:
return 'LINKS';
case FieldMetadataType.FULL_NAME:
return 'FULL_NAME';
case FieldMetadataType.NUMBER:
return 'NUMBER';
case FieldMetadataType.CURRENCY:
return 'CURRENCY';
case FieldMetadataType.EMAILS:
return 'EMAILS';
case FieldMetadataType.PHONES:
return 'PHONES';
case FieldMetadataType.RELATION:
return 'RELATION';
case FieldMetadataType.SELECT:
return 'SELECT';
case FieldMetadataType.MULTI_SELECT:
return 'MULTI_SELECT';
case FieldMetadataType.ADDRESS:
return 'ADDRESS';
case FieldMetadataType.RATING:
return 'RATING';
case FieldMetadataType.ACTOR:
return 'ACTOR';
case FieldMetadataType.ARRAY:
return 'ARRAY';
case FieldMetadataType.RAW_JSON:
return 'RAW_JSON';
case FieldMetadataType.BOOLEAN:
return 'BOOLEAN';
case FieldMetadataType.TS_VECTOR:
return 'TS_VECTOR';
case FieldMetadataType.UUID:
return 'UUID';
default:
return 'TEXT';
}
};
@@ -0,0 +1,24 @@
import { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from '@/constants/CompositeFieldTypeSubFieldsNames';
import { type CompositeFieldSubFieldName } from '@/types';
type CompositeMap = typeof COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES;
export const isExpectedSubFieldName = <
FieldMetadataType extends keyof CompositeMap,
PossibleSubFieldName extends CompositeFieldSubFieldName,
>(
fieldMetadataType: FieldMetadataType,
subFieldName: PossibleSubFieldName,
subFieldNameToCheck: string | null | undefined,
): subFieldNameToCheck is PossibleSubFieldName => {
const allowedSubFields = Object.values(COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES[
fieldMetadataType
]) as readonly string[];
return (
allowedSubFields.includes(subFieldName as string) &&
subFieldName === subFieldNameToCheck
);
};
@@ -0,0 +1,185 @@
import { ViewFilterOperand } from '@/types';
import {
addDays,
addMonths,
addWeeks,
addYears,
endOfDay,
endOfMonth,
endOfWeek,
endOfYear,
roundToNearestMinutes,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import { z } from 'zod';
const variableDateViewFilterValueDirectionSchema = z.enum([
'NEXT',
'THIS',
'PAST',
]);
export type VariableDateViewFilterValueDirection = z.infer<
typeof variableDateViewFilterValueDirectionSchema
>;
const variableDateViewFilterValueAmountSchema = z
.union([z.coerce.number().int().positive(), z.literal('undefined')])
.transform((val) => (val === 'undefined' ? undefined : val));
export const variableDateViewFilterValueUnitSchema = z.enum([
'DAY',
'WEEK',
'MONTH',
'YEAR',
]);
export type VariableDateViewFilterValueUnit = z.infer<
typeof variableDateViewFilterValueUnitSchema
>;
export const variableDateViewFilterValuePartsSchema = z
.object({
direction: variableDateViewFilterValueDirectionSchema,
amount: variableDateViewFilterValueAmountSchema,
unit: variableDateViewFilterValueUnitSchema,
})
.refine((data) => !(data.amount === undefined && data.direction !== 'THIS'), {
error: "Amount cannot be 'undefined' unless direction is 'THIS'",
});
const variableDateViewFilterValueSchema = z.string().transform((value) => {
const [direction, amount, unit] = value.split('_');
return variableDateViewFilterValuePartsSchema.parse({
direction,
amount,
unit,
});
});
const addUnit = (
date: Date,
amount: number,
unit: VariableDateViewFilterValueUnit,
) => {
switch (unit) {
case 'DAY':
return addDays(date, amount);
case 'WEEK':
return addWeeks(date, amount);
case 'MONTH':
return addMonths(date, amount);
case 'YEAR':
return addYears(date, amount);
}
};
const subUnit = (
date: Date,
amount: number,
unit: VariableDateViewFilterValueUnit,
) => {
switch (unit) {
case 'DAY':
return subDays(date, amount);
case 'WEEK':
return subWeeks(date, amount);
case 'MONTH':
return subMonths(date, amount);
case 'YEAR':
return subYears(date, amount);
}
};
const startOfUnit = (date: Date, unit: VariableDateViewFilterValueUnit) => {
switch (unit) {
case 'DAY':
return startOfDay(date);
case 'WEEK':
return startOfWeek(date);
case 'MONTH':
return startOfMonth(date);
case 'YEAR':
return startOfYear(date);
}
};
const endOfUnit = (date: Date, unit: VariableDateViewFilterValueUnit) => {
switch (unit) {
case 'DAY':
return endOfDay(date);
case 'WEEK':
return endOfWeek(date);
case 'MONTH':
return endOfMonth(date);
case 'YEAR':
return endOfYear(date);
}
};
const resolveVariableDateViewFilterValueFromRelativeDate = (relativeDate: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
}) => {
const { direction, amount, unit } = relativeDate;
const now = roundToNearestMinutes(new Date());
switch (direction) {
case 'NEXT':
if (amount === undefined) throw new Error('Amount is required');
return {
start: now,
end: addUnit(now, amount, unit),
...relativeDate,
};
case 'PAST':
if (amount === undefined) throw new Error('Amount is required');
return {
start: subUnit(now, amount, unit),
end: now,
...relativeDate,
};
case 'THIS':
return {
start: startOfUnit(now, unit),
end: endOfUnit(now, unit),
...relativeDate,
};
}
};
const resolveVariableDateViewFilterValue = (value?: string | null) => {
if (!value) return null;
const relativeDate = variableDateViewFilterValueSchema.parse(value);
return resolveVariableDateViewFilterValueFromRelativeDate(relativeDate);
};
export type ResolvedDateViewFilterValue<O extends ViewFilterOperand> =
O extends ViewFilterOperand.IsRelative
? ReturnType<typeof resolveVariableDateViewFilterValue>
: Date | null;
type PartialViewFilter<O extends ViewFilterOperand> = {value: string, operand: O} // TODO, was done to avoid ViewFilter export
export const resolveDateViewFilterValue = <O extends ViewFilterOperand>(
viewFilter: PartialViewFilter<O>,
): ResolvedDateViewFilterValue<O> => {
if (!viewFilter.value) return null;
if (viewFilter.operand === ViewFilterOperand.IsRelative) {
return resolveVariableDateViewFilterValue(
viewFilter.value,
) as ResolvedDateViewFilterValue<O>;
}
return new Date(viewFilter.value) as ResolvedDateViewFilterValue<O>;
};
@@ -0,0 +1,19 @@
import { isValidVariable } from '@/utils';
import { z } from 'zod';
export const arrayOfStringsOrVariablesSchema = z
.string()
.transform((val) => {
if (val === '') return [];
if (isValidVariable(val) as boolean) {
return [val];
}
return JSON.parse(val);
})
.refine(
(parsed) =>
Array.isArray(parsed) && parsed.every((item) => typeof item === 'string'),
{
error: 'Expected an array of strings',
},
);
@@ -0,0 +1,30 @@
import { isValidUuid, isValidVariable } from '@/utils';
import { z } from 'zod';
export const arrayOfUuidOrVariableSchema = z
.preprocess(
(value) => {
try {
if (typeof value === 'string') {
if (isValidVariable(value) as boolean) {
return [value];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [value];
}
}
return Array.isArray(value) ? value : [value];
} catch {
return [];
}
},
z.array(
z.string().refine((val) => {
return isValidUuid(val) || isValidVariable(val);
}, 'Must be a valid UUID or a variable with {{ }} syntax'),
),
)
.catch([]);
@@ -0,0 +1,21 @@
import { z } from 'zod';
export const relationFilterValueSchemaObject = z.object({
isCurrentWorkspaceMemberSelected: z.boolean().optional(),
selectedRecordIds: z.array(z.string()),
});
export const jsonRelationFilterValueSchema = z
.string()
.transform((value, ctx) => {
try {
return JSON.parse(value);
} catch (error) {
ctx.addIssue({
code: 'custom',
message: (error as Error).message,
});
return z.NEVER;
}
})
.pipe(relationFilterValueSchemaObject);
+42
View File
@@ -16,8 +16,50 @@ export { sumByProperty } from './array/sumByProperty';
export { assertUnreachable } from './assertUnreachable';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export { deepMerge } from './deepMerge';
export { CustomError } from './errors/CustomError';
export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';
export { checkIfShouldSkipFiltering } from './filter/checkIfShouldSkipFiltering';
export { computeGqlOperationFilterForEmails } from './filter/compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForEmails';
export { computeGqlOperationFilterForLinks } from './filter/compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForLinks';
export { computeEmptyGqlOperationFilterForEmails } from './filter/computeEmptyGqlOperationFilterForEmails';
export { computeEmptyGqlOperationFilterForLinks } from './filter/computeEmptyGqlOperationFilterForLinks';
export { computeRecordGqlOperationFilter } from './filter/computeRecordGqlOperationFilter';
export { isEmptinessOperand } from './filter/isEmptinessOperand';
export type {
RecordFilter,
RecordFilterGroup,
} from './filter/turnRecordFilterGroupIntoGqlOperationFilter';
export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecordFilterGroupIntoGqlOperationFilter';
export { turnRecordFilterIntoRecordGqlOperationFilter } from './filter/turnRecordFilterIntoGqlOperationFilter';
export { combineFilters } from './filter/utils/combineFilters';
export { convertViewFilterValueToString } from './filter/utils/convertViewFilterValueToString';
export {
convertGreaterThanOrEqualRatingToArrayOfRatingValues,
convertLessThanOrEqualRatingToArrayOfRatingValues,
convertRatingToRatingValue,
} from './filter/utils/fieldRatingConvertors';
export { generateILikeFiltersForCompositeFields } from './filter/utils/generateILikeFiltersForCompositeFields';
export { getEmptyRecordGqlOperationFilter } from './filter/utils/getEmptyRecordGqlOperationFilter';
export { getFilterTypeFromFieldType } from './filter/utils/getFilterTypeFromFieldType';
export { isExpectedSubFieldName } from './filter/utils/isExpectedSubFieldName';
export type {
VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit,
ResolvedDateViewFilterValue,
} from './filter/utils/resolveDateViewFilterValue';
export {
variableDateViewFilterValueUnitSchema,
variableDateViewFilterValuePartsSchema,
resolveDateViewFilterValue,
} from './filter/utils/resolveDateViewFilterValue';
export { arrayOfStringsOrVariablesSchema } from './filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
export { arrayOfUuidOrVariableSchema } from './filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
export {
relationFilterValueSchemaObject,
jsonRelationFilterValueSchema,
} from './filter/utils/validation-schemas/jsonRelationFilterValueSchema';
export { fromArrayToUniqueKeyRecord } from './from-array-to-unique-key-record.util';
export { fromArrayToValuesByKeyRecord } from './fromArrayToValuesByKeyRecord.util';
export { getURLSafely } from './getURLSafely';