Feat/record optimistic effect (#3076)

* WIP

* WIP

* POC working on hard coded completedAt field

* Finished isRecordMatchingFilter, mock of pg_graphql filtering mechanism

* Fixed and cleaned

* Unregister unused optimistic effects

* Fix lint

* Fixes from review

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-12-20 20:31:48 +01:00
committed by GitHub
parent a5f28b4395
commit 687c9131f4
37 changed files with 2309 additions and 233 deletions

View File

@ -1,68 +1,109 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { produce } from 'immer';
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const getRecordOptimisticEffectDefinition = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) =>
({
key: `record-create-optimistic-effect-definition-${objectMetadataItem.nameSingular}`,
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
resolver: ({
currentData,
newData,
deletedRecordIds,
}: {
currentData: unknown;
newData: { id: string } & Record<string, any>;
deletedRecordIds?: string[];
}) => {
const newRecordPaginatedCacheField = produce<
PaginatedRecordTypeResults<any>
>(currentData as PaginatedRecordTypeResults<any>, (draft) => {
if (newData) {
if (!draft) {
return {
__typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
edges: [{ node: newData, cursor: '' }],
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
};
}
}): OptimisticEffectDefinition => ({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
resolver: ({
currentCacheData: currentData,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
}) => {
const newRecordPaginatedCacheField = produce<
PaginatedRecordTypeResults<any>
>(currentData as PaginatedRecordTypeResults<any>, (draft) => {
const existingDataIsEmpty = !draft || !draft.edges || !draft.edges[0];
const existingRecord = draft.edges.find(
(edge) => edge.node.id === newData.id,
);
if (existingRecord) {
existingRecord.node = newData;
return;
}
draft.edges.unshift({
node: newData,
cursor: '',
if (isNonEmptyArray(createdRecords)) {
if (existingDataIsEmpty) {
return {
__typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
});
}
edges: createdRecords.map((createdRecord) => ({
node: createdRecord,
cursor: '',
})),
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
};
} else {
for (const createdRecord of createdRecords) {
const existingRecord = draft.edges.find(
(edge) => edge.node.id === createdRecord.id,
);
if (deletedRecordIds) {
draft.edges = draft.edges.filter(
(edge) => !deletedRecordIds.includes(edge.node.id),
);
}
});
if (existingRecord) {
existingRecord.node = createdRecord;
continue;
}
return newRecordPaginatedCacheField;
},
isUsingFlexibleBackend: true,
objectMetadataItem,
}) satisfies OptimisticEffectDefinition;
draft.edges.unshift({
node: createdRecord,
cursor: '',
__typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
});
}
}
}
if (deletedRecordIds) {
draft.edges = draft.edges.filter(
(edge) => !deletedRecordIds.includes(edge.node.id),
);
}
if (isNonEmptyArray(updatedRecords)) {
for (const updatedRecord of updatedRecords) {
const updatedRecordIsOutOfQueryFilter =
isDefined(variables.filter) &&
!isRecordMatchingFilter({
record: updatedRecord,
filter: variables.filter,
objectMetadataItem,
});
if (updatedRecordIsOutOfQueryFilter) {
draft.edges = draft.edges.filter(
(edge) => edge.node.id !== updatedRecord.id,
);
} else {
const foundUpdatedRecordInCacheQuery = draft.edges.find(
(edge) => edge.node.id === updatedRecord.id,
);
if (foundUpdatedRecordInCacheQuery) {
foundUpdatedRecordInCacheQuery.node = updatedRecord;
} else {
// TODO: add order by
draft.edges.push({
node: updatedRecord,
cursor: '',
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`,
});
}
}
}
}
});
return newRecordPaginatedCacheField;
},
objectMetadataItem,
});

View File

@ -7,7 +7,7 @@ import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMeta
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateManyRecords = <T>({
export const useCreateManyRecords = <T extends Record<string, unknown>>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { triggerOptimisticEffects } = useOptimisticEffect({
@ -32,10 +32,17 @@ export const useCreateManyRecords = <T>({
}));
withIds.forEach((record) => {
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
generateEmptyRecord({ id: record.id }),
);
const emptyRecord: Record<string, unknown> | undefined =
generateEmptyRecord({
id: record.id,
});
if (emptyRecord) {
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdRecords: [emptyRecord],
});
}
});
const createdObjects = await apolloClient.mutate({
@ -59,11 +66,9 @@ export const useCreateManyRecords = <T>({
`create${capitalize(objectMetadataItem.namePlural)}`
] as T[]) ?? [];
createdRecords.forEach((record) => {
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
record,
);
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdRecords,
});
return createdRecords;

View File

@ -1,5 +1,4 @@
import { useApolloClient } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { v4 } from 'uuid';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
@ -14,16 +13,16 @@ type useCreateOneRecordProps = {
export const useCreateOneRecord = <T>({
objectNameSingular,
refetchFindManyQuery = false,
}: useCreateOneRecordProps) => {
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const { objectMetadataItem, createOneRecordMutation, findManyRecordsQuery } =
useObjectMetadataItem({
const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem(
{
objectNameSingular,
});
},
);
// TODO: type this with a minimal type at least with Record<string, any>
const apolloClient = useApolloClient();
@ -35,16 +34,16 @@ export const useCreateOneRecord = <T>({
const createOneRecord = async (input: Record<string, any>) => {
const recordId = v4();
const generatedEmptyRecord = generateEmptyRecord({
const generatedEmptyRecord = generateEmptyRecord<Record<string, unknown>>({
id: recordId,
...input,
});
if (generatedEmptyRecord) {
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
generatedEmptyRecord,
);
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdRecords: [generatedEmptyRecord],
});
}
const createdObject = await apolloClient.mutate({
@ -56,22 +55,12 @@ export const useCreateOneRecord = <T>({
[`create${capitalize(objectMetadataItem.nameSingular)}`]:
generateEmptyRecord({ id: recordId, ...input }),
},
refetchQueries: refetchFindManyQuery
? [getOperationName(findManyRecordsQuery) ?? '']
: [],
});
if (!createdObject.data) {
return null;
}
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdObject.data[
`create${capitalize(objectMetadataItem.nameSingular)}`
],
);
return createdObject.data[
`create${capitalize(objectMetadataItem.nameSingular)}`
] as T;

View File

@ -30,11 +30,10 @@ export const useDeleteOneRecord = <T>({
const deleteOneRecord = useCallback(
async (idToDelete: string) => {
triggerOptimisticEffects(
`${capitalize(objectMetadataItem.nameSingular)}Edge`,
undefined,
[idToDelete],
);
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
deletedRecordIds: [idToDelete],
});
performOptimisticEvict(
capitalize(objectMetadataItem.nameSingular),

View File

@ -4,13 +4,12 @@ import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useRecordOptimisticEffect } from '@/object-metadata/hooks/useRecordOptimisticEffect';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { ObjectRecordFilter } from '@/object-record/types/ObjectRecordFilter';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -37,7 +36,7 @@ export const useFindManyRecords = <
onCompleted,
skip,
}: ObjectMetadataItemIdentifier & {
filter?: ObjectRecordFilter;
filter?: ObjectRecordQueryFilter;
orderBy?: OrderByField;
limit?: number;
onCompleted?: (data: PaginatedRecordTypeResults<RecordType>) => void;
@ -65,8 +64,11 @@ export const useFindManyRecords = <
objectNameSingular,
});
const { registerOptimisticEffect } = useOptimisticEffect({
objectNameSingular,
useRecordOptimisticEffect({
objectMetadataItem,
filter,
orderBy,
limit,
});
const { enqueueSnackBar } = useSnackBar();
@ -82,19 +84,6 @@ export const useFindManyRecords = <
orderBy: orderBy ?? {},
},
onCompleted: (data) => {
if (objectMetadataItem) {
registerOptimisticEffect({
variables: {
filter: filter ?? {},
orderBy: orderBy ?? {},
limit: limit,
},
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem,
}),
});
}
onCompleted?.(data[objectMetadataItem.namePlural]);
if (data?.[objectMetadataItem.namePlural]) {

View File

@ -164,6 +164,6 @@ export const useGenerateEmptyRecord = ({
};
return {
generateEmptyRecord: generateEmptyRecord,
generateEmptyRecord,
};
};

View File

@ -5,8 +5,8 @@ import { Company } from '@/companies/types/Company';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordBoardScopedStates } from '@/object-record/record-board/hooks/internal/useRecordBoardScopedStates';
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { turnFiltersIntoObjectRecordFilters } from '@/object-record/utils/turnFiltersIntoWhereClause';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { PipelineStep } from '@/pipeline/types/PipelineStep';
@ -43,7 +43,7 @@ export const useObjectRecordBoard = () => {
savedPipelineStepsState,
);
const filter = turnFiltersIntoObjectRecordFilters(
const filter = turnObjectDropdownFilterIntoQueryFilter(
boardFilters,
foundObjectMetadataItem?.fields ?? [],
);

View File

@ -4,10 +4,10 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
import { useRecordTableScopedStates } from '@/object-record/record-table/hooks/internal/useRecordTableScopedStates';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { isRecordTableInitialLoadingState } from '@/object-record/record-table/states/isRecordTableInitialLoadingState';
import { turnFiltersIntoObjectRecordFilters } from '@/object-record/utils/turnFiltersIntoWhereClause';
import { signInBackgroundMockCompanies } from '@/sign-in-background-mock/constants/signInBackgroundMockCompanies';
import { useFindManyRecords } from './useFindManyRecords';
@ -32,7 +32,7 @@ export const useObjectRecordTable = () => {
const tableSorts = useRecoilValue(tableSortsState);
const setLastRowVisible = useSetRecoilState(tableLastRowVisibleState);
const requestFilters = turnFiltersIntoObjectRecordFilters(
const requestFilters = turnObjectDropdownFilterIntoQueryFilter(
tableFilters,
foundObjectMetadataItem?.fields ?? [],
);

View File

@ -1,6 +1,6 @@
import { useApolloClient } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
@ -11,14 +11,13 @@ type useUpdateOneRecordProps = {
export const useUpdateOneRecord = <T>({
objectNameSingular,
refetchFindManyQuery = false,
}: useUpdateOneRecordProps) => {
const {
objectMetadataItem,
updateOneRecordMutation,
getRecordFromCache,
findManyRecordsQuery,
} = useObjectMetadataItem({
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({
objectNameSingular,
});
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
@ -34,6 +33,16 @@ export const useUpdateOneRecord = <T>({
}) => {
const cachedRecord = getRecordFromCache(idToUpdate);
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
updatedRecords: [
{
...(cachedRecord ?? {}),
...input,
},
],
});
const updatedRecord = await apolloClient.mutate({
mutation: updateOneRecordMutation,
variables: {
@ -48,18 +57,17 @@ export const useUpdateOneRecord = <T>({
...input,
},
},
refetchQueries: refetchFindManyQuery
? [getOperationName(findManyRecordsQuery) ?? '']
: [],
});
if (!updatedRecord?.data) {
return null;
}
return updatedRecord.data[
const updatedData = updatedRecord.data[
`update${capitalize(objectMetadataItem.nameSingular)}`
] as T;
return updatedData;
};
return {

View File

@ -9,6 +9,11 @@ export type UUIDFilter = {
is?: IsFilter;
};
export type BooleanFilter = {
eq?: boolean;
is?: IsFilter;
};
export type StringFilter = {
eq?: string;
gt?: string;
@ -36,6 +41,11 @@ export type FloatFilter = {
is?: IsFilter;
};
/**
* Always use a DateFilter in the variables of a query, and never directly in the query.
*
* Because pg_graphql only works with ISO strings if it is passed to variables.
*/
export type DateFilter = {
eq?: string;
gt?: string;
@ -53,6 +63,7 @@ export type CurrencyFilter = {
export type URLFilter = {
url?: StringFilter;
label?: StringFilter;
};
export type FullNameFilter = {
@ -67,14 +78,27 @@ export type LeafFilter =
| DateFilter
| CurrencyFilter
| URLFilter
| FullNameFilter;
| FullNameFilter
| BooleanFilter;
export type ObjectRecordFilter =
| {
and?: ObjectRecordFilter[];
or?: ObjectRecordFilter[];
not?: ObjectRecordFilter;
}
| {
[fieldName: string]: LeafFilter;
};
export type AndObjectRecordFilter = {
and?: ObjectRecordQueryFilter[];
};
export type OrObjectRecordFilter = {
or?: ObjectRecordQueryFilter[] | ObjectRecordQueryFilter;
};
export type NotObjectRecordFilter = {
not?: ObjectRecordQueryFilter;
};
export type LeafObjectRecordFilter = {
[fieldName: string]: LeafFilter;
};
export type ObjectRecordQueryFilter =
| LeafObjectRecordFilter
| AndObjectRecordFilter
| OrObjectRecordFilter
| NotObjectRecordFilter;

View File

@ -0,0 +1,46 @@
import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter';
describe('isMatchingBooleanFilter', () => {
describe('eq', () => {
it('value equals eq filter', () => {
expect(
isMatchingBooleanFilter({ booleanFilter: { eq: true }, value: true }),
).toBe(true);
});
it('value does not equal eq filter', () => {
expect(
isMatchingBooleanFilter({ booleanFilter: { eq: true }, value: false }),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
expect(
isMatchingBooleanFilter({
booleanFilter: { is: 'NULL' },
value: null as any,
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
expect(
isMatchingBooleanFilter({
booleanFilter: { is: 'NOT_NULL' },
value: true,
}),
).toBe(true);
});
it('value is NOT_NULL and false', () => {
expect(
isMatchingBooleanFilter({
booleanFilter: { is: 'NOT_NULL' },
value: false,
}),
).toBe(true);
});
});
});

View File

@ -0,0 +1,23 @@
import { BooleanFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const isMatchingBooleanFilter = ({
booleanFilter,
value,
}: {
booleanFilter: BooleanFilter;
value: boolean;
}) => {
if (booleanFilter.eq !== undefined) {
return value === booleanFilter.eq;
} else if (booleanFilter.is !== undefined) {
if (booleanFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
} else {
throw new Error(
`Unexpected value for string filter : ${JSON.stringify(booleanFilter)}`,
);
}
};

View File

@ -0,0 +1,161 @@
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
describe('isMatchingDateFilter', () => {
const testDate = '2023-12-19T12:15:29.810Z';
describe('eq', () => {
it('value equals eq filter', () => {
expect(
isMatchingDateFilter({ dateFilter: { eq: testDate }, value: testDate }),
).toBe(true);
});
it('value does not equal eq filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { eq: testDate },
value: '2023-12-18T12:15:29.810Z',
}),
).toBe(false);
});
});
describe('neq', () => {
it('value does not equal neq filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { neq: testDate },
value: '2023-12-18T12:15:29.810Z',
}),
).toBe(true);
});
it('value equals neq filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { neq: testDate },
value: testDate,
}),
).toBe(false);
});
});
describe('in', () => {
it('value is in the array', () => {
expect(
isMatchingDateFilter({
dateFilter: { in: [testDate, '2023-12-20T12:15:29.810Z'] },
value: testDate,
}),
).toBe(true);
});
it('value is not in the array', () => {
expect(
isMatchingDateFilter({
dateFilter: {
in: ['2023-12-20T12:15:29.810Z', '2023-12-21T12:15:29.810Z'],
},
value: testDate,
}),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
expect(
isMatchingDateFilter({
dateFilter: { is: 'NULL' },
value: null as any,
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
expect(
isMatchingDateFilter({
dateFilter: { is: 'NOT_NULL' },
value: testDate,
}),
).toBe(true);
});
});
describe('gt', () => {
it('value is greater than gt filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { gt: '2023-12-18T12:15:29.810Z' },
value: testDate,
}),
).toBe(true);
});
it('value is not greater than gt filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { gt: '2023-12-20T12:15:29.810Z' },
value: testDate,
}),
).toBe(false);
});
});
describe('gte', () => {
it('value is greater than or equal to gte filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { gte: testDate },
value: testDate,
}),
).toBe(true);
});
it('value is not greater than or equal to gte filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { gte: '2023-12-20T12:15:29.810Z' },
value: testDate,
}),
).toBe(false);
});
});
describe('lt', () => {
it('value is less than lt filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { lt: '2023-12-20T12:15:29.810Z' },
value: testDate,
}),
).toBe(true);
});
it('value is not less than lt filter', () => {
expect(
isMatchingDateFilter({ dateFilter: { lt: testDate }, value: testDate }),
).toBe(false);
});
});
describe('lte', () => {
it('value is less than or equal to lte filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { lte: testDate },
value: testDate,
}),
).toBe(true);
});
it('value is not less than or equal to lte filter', () => {
expect(
isMatchingDateFilter({
dateFilter: { lte: '2023-12-18T12:15:29.810Z' },
value: testDate,
}),
).toBe(false);
});
});
});

View File

@ -0,0 +1,47 @@
import { DateTime } from 'luxon';
import { DateFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const isMatchingDateFilter = ({
dateFilter,
value,
}: {
dateFilter: DateFilter;
value: string;
}) => {
switch (true) {
case dateFilter.eq !== undefined: {
return DateTime.fromISO(value).equals(DateTime.fromISO(dateFilter.eq));
}
case dateFilter.neq !== undefined: {
return !DateTime.fromISO(value).equals(DateTime.fromISO(dateFilter.neq));
}
case dateFilter.in !== undefined: {
return dateFilter.in.includes(value);
}
case dateFilter.is !== undefined: {
if (dateFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
case dateFilter.gt !== undefined: {
return DateTime.fromISO(value) > DateTime.fromISO(dateFilter.gt);
}
case dateFilter.gte !== undefined: {
return DateTime.fromISO(value) >= DateTime.fromISO(dateFilter.gte);
}
case dateFilter.lt !== undefined: {
return DateTime.fromISO(value) < DateTime.fromISO(dateFilter.lt);
}
case dateFilter.lte !== undefined: {
return DateTime.fromISO(value) <= DateTime.fromISO(dateFilter.lte);
}
default: {
throw new Error(
`Unexpected value for string filter : ${JSON.stringify(dateFilter)}`,
);
}
}
};

View File

@ -0,0 +1,118 @@
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
describe('isMatchingFloatFilter', () => {
describe('eq', () => {
it('value equals eq filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { eq: 10 }, value: 10 }),
).toBe(true);
});
it('value does not equal eq filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { eq: 10 }, value: 20 }),
).toBe(false);
});
});
describe('neq', () => {
it('value does not equal neq filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { neq: 10 }, value: 20 }),
).toBe(true);
});
it('value equals neq filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { neq: 10 }, value: 10 }),
).toBe(false);
});
});
describe('gt', () => {
it('value is greater than gt filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { gt: 10 }, value: 20 }),
).toBe(true);
});
it('value is not greater than gt filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { gt: 20 }, value: 10 }),
).toBe(false);
});
});
describe('gte', () => {
it('value is greater than or equal to gte filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { gte: 10 }, value: 10 }),
).toBe(true);
});
it('value is not greater than or equal to gte filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { gte: 20 }, value: 10 }),
).toBe(false);
});
});
describe('lt', () => {
it('value is less than lt filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { lt: 20 }, value: 10 }),
).toBe(true);
});
it('value is not less than lt filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { lt: 10 }, value: 20 }),
).toBe(false);
});
});
describe('lte', () => {
it('value is less than or equal to lte filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { lte: 10 }, value: 10 }),
).toBe(true);
});
it('value is not less than or equal to lte filter', () => {
expect(
isMatchingFloatFilter({ floatFilter: { lte: 10 }, value: 20 }),
).toBe(false);
});
});
describe('in', () => {
it('value is in the array', () => {
expect(
isMatchingFloatFilter({ floatFilter: { in: [10, 20, 30] }, value: 20 }),
).toBe(true);
});
it('value is not in the array', () => {
expect(
isMatchingFloatFilter({ floatFilter: { in: [10, 30, 40] }, value: 20 }),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
expect(
isMatchingFloatFilter({
floatFilter: { is: 'NULL' },
value: null as any,
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
expect(
isMatchingFloatFilter({ floatFilter: { is: 'NOT_NULL' }, value: 10 }),
).toBe(true);
});
});
});

View File

@ -0,0 +1,45 @@
import { FloatFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const isMatchingFloatFilter = ({
floatFilter,
value,
}: {
floatFilter: FloatFilter;
value: number;
}) => {
switch (true) {
case floatFilter.eq !== undefined: {
return value === floatFilter.eq;
}
case floatFilter.neq !== undefined: {
return value !== floatFilter.neq;
}
case floatFilter.gt !== undefined: {
return value > floatFilter.gt;
}
case floatFilter.gte !== undefined: {
return value >= floatFilter.gte;
}
case floatFilter.lt !== undefined: {
return value < floatFilter.lt;
}
case floatFilter.lte !== undefined: {
return value <= floatFilter.lte;
}
case floatFilter.in !== undefined: {
return floatFilter.in.includes(value);
}
case floatFilter.is !== undefined: {
if (floatFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
default: {
throw new Error(
`Unexpected value for float filter : ${JSON.stringify(floatFilter)}`,
);
}
}
};

View File

@ -0,0 +1,236 @@
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
describe('isMatchingStringFilter', () => {
describe('eq', () => {
it('value equals eq filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { eq: 'test' }, value: 'test' }),
).toBe(true);
});
it('value does not equals eq filter', () => {
expect(
isMatchingStringFilter({
stringFilter: { eq: 'test' },
value: 'other',
}),
).toBe(false);
});
});
describe('neq', () => {
it('value does not equal neq filter', () => {
expect(
isMatchingStringFilter({
stringFilter: { neq: 'test' },
value: 'other',
}),
).toBe(true);
});
it('value equals neq filter', () => {
expect(
isMatchingStringFilter({
stringFilter: { neq: 'test' },
value: 'test',
}),
).toBe(false);
});
});
describe('like', () => {
it('value matches like pattern', () => {
expect(
isMatchingStringFilter({
stringFilter: { like: 'te%' },
value: 'test',
}),
).toBe(true);
});
it('value does not match like pattern', () => {
expect(
isMatchingStringFilter({
stringFilter: { like: 'ab%' },
value: 'test',
}),
).toBe(false);
});
});
describe('ilike', () => {
it('value matches ilike pattern case insensitively', () => {
expect(
isMatchingStringFilter({
stringFilter: { ilike: 'TE%' },
value: 'test',
}),
).toBe(true);
});
it('value does not match ilike pattern', () => {
expect(
isMatchingStringFilter({
stringFilter: { ilike: 'AB%' },
value: 'test',
}),
).toBe(false);
});
});
describe('in', () => {
it('value is in the array', () => {
expect(
isMatchingStringFilter({
stringFilter: { in: ['test', 'example'] },
value: 'test',
}),
).toBe(true);
});
it('value is not in the array', () => {
expect(
isMatchingStringFilter({
stringFilter: { in: ['example', 'sample'] },
value: 'test',
}),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
expect(
isMatchingStringFilter({
stringFilter: { is: 'NULL' },
value: null as any,
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
expect(
isMatchingStringFilter({
stringFilter: { is: 'NOT_NULL' },
value: 'test',
}),
).toBe(true);
});
});
describe('regex', () => {
it('value matches regex pattern', () => {
expect(
isMatchingStringFilter({
stringFilter: { regex: '^test$' },
value: 'test',
}),
).toBe(true);
});
it('value does not match regex pattern', () => {
expect(
isMatchingStringFilter({
stringFilter: { regex: '^test$' },
value: 'testing',
}),
).toBe(false);
});
});
describe('iregex', () => {
it('value matches iregex pattern case insensitively', () => {
expect(
isMatchingStringFilter({
stringFilter: { iregex: '^test$' },
value: 'Test',
}),
).toBe(true);
});
it('value does not match iregex pattern', () => {
expect(
isMatchingStringFilter({
stringFilter: { iregex: '^test$' },
value: 'testing',
}),
).toBe(false);
});
});
describe('gt', () => {
it('value is greater than gt filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { gt: 'a' }, value: 'b' }),
).toBe(true);
});
it('value is not greater than gt filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { gt: 'b' }, value: 'a' }),
).toBe(false);
});
});
describe('gte', () => {
it('value is greater than or equal to gte filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { gte: 'a' }, value: 'a' }),
).toBe(true);
});
it('value is not greater than or equal to gte filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { gte: 'b' }, value: 'a' }),
).toBe(false);
});
});
describe('lt', () => {
it('value is less than lt filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { lt: 'b' }, value: 'a' }),
).toBe(true);
});
it('value is not less than lt filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { lt: 'a' }, value: 'b' }),
).toBe(false);
});
});
describe('lte', () => {
it('value is less than or equal to lte filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'a' }),
).toBe(true);
});
it('value is not less than or equal to lte filter', () => {
expect(
isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'b' }),
).toBe(false);
});
});
describe('startsWith', () => {
it('value starts with the startsWith filter', () => {
expect(
isMatchingStringFilter({
stringFilter: { startsWith: 'te' },
value: 'test',
}),
).toBe(true);
});
it('value does not start with the startsWith filter', () => {
expect(
isMatchingStringFilter({
stringFilter: { startsWith: 'st' },
value: 'test',
}),
).toBe(false);
});
});
});

View File

@ -0,0 +1,72 @@
import { StringFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const isMatchingStringFilter = ({
stringFilter,
value,
}: {
stringFilter: StringFilter;
value: string;
}) => {
switch (true) {
case stringFilter.eq !== undefined: {
return value === stringFilter.eq;
}
case stringFilter.neq !== undefined: {
return value !== stringFilter.neq;
}
case stringFilter.like !== undefined: {
const regexPattern = stringFilter.like.replace(/%/g, '.*');
const regexCaseSensitive = new RegExp(`^${regexPattern}$`);
return regexCaseSensitive.test(value);
}
case stringFilter.ilike !== undefined: {
const regexPattern = stringFilter.ilike.replace(/%/g, '.*');
const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'i');
return regexCaseInsensitive.test(value);
}
case stringFilter.in !== undefined: {
return stringFilter.in.includes(value);
}
case stringFilter.is !== undefined: {
if (stringFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
case stringFilter.regex !== undefined: {
const regexPattern = stringFilter.regex;
const regexCaseSensitive = new RegExp(regexPattern);
return regexCaseSensitive.test(value);
}
case stringFilter.iregex !== undefined: {
const regexPattern = stringFilter.iregex;
const regexCaseInsensitive = new RegExp(regexPattern, 'i');
return regexCaseInsensitive.test(value);
}
case stringFilter.gt !== undefined: {
return value > stringFilter.gt;
}
case stringFilter.gte !== undefined: {
return value >= stringFilter.gte;
}
case stringFilter.lt !== undefined: {
return value < stringFilter.lt;
}
case stringFilter.lte !== undefined: {
return value <= stringFilter.lte;
}
case stringFilter.startsWith !== undefined: {
return value.startsWith(stringFilter.startsWith);
}
default: {
throw new Error(
`Unexpected value for string filter : ${JSON.stringify(stringFilter)}`,
);
}
}
};

View File

@ -0,0 +1,82 @@
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
describe('isMatchingUUIDFilter', () => {
const testUUID = '123e4567-e89b-12d3-a456-426655440000';
describe('eq', () => {
it('value equals eq filter', () => {
expect(
isMatchingUUIDFilter({ uuidFilter: { eq: testUUID }, value: testUUID }),
).toBe(true);
});
it('value does not equal eq filter', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { eq: testUUID },
value: 'different-uuid',
}),
).toBe(false);
});
});
describe('neq', () => {
it('value does not equal neq filter', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { neq: testUUID },
value: 'different-uuid',
}),
).toBe(true);
});
it('value equals neq filter', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { neq: testUUID },
value: testUUID,
}),
).toBe(false);
});
});
describe('in', () => {
it('value is in the array', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { in: [testUUID, 'another-uuid'] },
value: testUUID,
}),
).toBe(true);
});
it('value is not in the array', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { in: ['another-uuid', 'yet-another-uuid'] },
value: testUUID,
}),
).toBe(false);
});
});
describe('is', () => {
it('value is NULL', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { is: 'NULL' },
value: null as any,
}),
).toBe(true);
});
it('value is NOT_NULL', () => {
expect(
isMatchingUUIDFilter({
uuidFilter: { is: 'NOT_NULL' },
value: testUUID,
}),
).toBe(true);
});
});
});

View File

@ -0,0 +1,36 @@
import {
UUIDFilter,
UUIDFilterValue,
} from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const isMatchingUUIDFilter = ({
uuidFilter,
value,
}: {
uuidFilter: UUIDFilter;
value: UUIDFilterValue;
}) => {
switch (true) {
case uuidFilter.eq !== undefined: {
return value === uuidFilter.eq;
}
case uuidFilter.neq !== undefined: {
return value !== uuidFilter.neq;
}
case uuidFilter.in !== undefined: {
return uuidFilter.in.includes(value);
}
case uuidFilter.is !== undefined: {
if (uuidFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
default: {
throw new Error(
`Unexpected value for string filter : ${JSON.stringify(uuidFilter)}`,
);
}
}
};

View File

@ -0,0 +1,344 @@
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { mockObjectMetadataItem } from '~/testing/mock-data/objectMetadataItems';
import { isRecordMatchingFilter } from './isRecordMatchingFilter';
describe('isRecordMatchingFilter', () => {
describe('Empty Filters', () => {
it('matches any record when no filter is provided', () => {
const emptyFilter = {};
mockedCompaniesData.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter: emptyFilter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
});
});
it('matches any record when filter fields are empty', () => {
const filterWithEmptyFields = {
name: {},
employees: {},
};
mockedCompaniesData.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter: filterWithEmptyFields,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
});
});
it('matches any record with an empty and filter', () => {
const filter = { and: [] };
mockedCompaniesData.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
});
});
it('matches any record with an empty or filter', () => {
const filter = { or: [] };
mockedCompaniesData.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
});
});
it('matches any record with an empty not filter', () => {
const filter = { not: {} };
mockedCompaniesData.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
});
});
});
describe('Simple Filters', () => {
it('matches a record with a simple equality filter on name', () => {
const filter = { name: { eq: 'Airbnb' } };
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
it('matches a record with a simple equality filter on domainName', () => {
const filter = { domainName: { eq: 'airbnb.com' } };
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
it('matches a record with a greater than filter on employees', () => {
const filter = { employees: { gt: 10 } };
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
it('matches a record with a boolean filter on idealCustomerProfile', () => {
const filter = { idealCustomerProfile: { eq: true } };
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[4], // Assuming this record has idealCustomerProfile as false
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
});
describe('Complex And/Or/Not Nesting', () => {
it('matches record with a combination of and + or filters', () => {
const filter: ObjectRecordQueryFilter = {
and: [
{ domainName: { eq: 'airbnb.com' } },
{
or: [
{ employees: { gt: 10 } },
{ idealCustomerProfile: { eq: true } },
],
},
],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1], // Aircall
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
it('matches record with nested not filter', () => {
const filter: ObjectRecordQueryFilter = {
not: {
and: [
{ name: { eq: 'Airbnb' } },
{ idealCustomerProfile: { eq: true } },
],
},
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false); // Should not match as it's Airbnb with idealCustomerProfile true
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[3], // Apple
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true); // Should match as it's not Airbnb
});
it('matches record with deep nesting of and, or, and not filters', () => {
const filter: ObjectRecordQueryFilter = {
and: [
{ domainName: { eq: 'apple.com' } },
{
or: [{ employees: { eq: 10 } }, { not: { name: { eq: 'Apple' } } }],
},
],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[3], // Apple
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[4], // Qonto
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
it('matches record with and filter at root level', () => {
const filter: ObjectRecordQueryFilter = {
and: [
{ name: { eq: 'Facebook' } },
{ idealCustomerProfile: { eq: true } },
],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[5], // Facebook
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
it('matches record with or filter at root level including a not condition', () => {
const filter: ObjectRecordQueryFilter = {
or: [{ name: { eq: 'Sequoia' } }, { not: { employees: { eq: 1 } } }],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[6], // Sequoia
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1], // Aircall
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
});
describe('Implicit And Conditions', () => {
it('matches record with implicit and of multiple operators within the same field', () => {
const filter = {
employees: { gt: 10, lt: 100000 },
name: { eq: 'Airbnb' },
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true); // Matches as Airbnb's employee count is between 10 and 100000
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1], // Aircall
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false); // Does not match as Aircall's employee count is not within the range
});
it('matches record with implicit and within an object passed to or', () => {
const filter = {
or: {
name: { eq: 'Airbnb' },
domainName: { eq: 'airbnb.com' },
},
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[2], // Algolia
filter,
objectMetadataItem: mockObjectMetadataItem,
}),
).toBe(false);
});
});
});

View File

@ -0,0 +1,269 @@
import { isObject } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AndObjectRecordFilter,
BooleanFilter,
DateFilter,
FloatFilter,
FullNameFilter,
LeafObjectRecordFilter,
NotObjectRecordFilter,
ObjectRecordQueryFilter,
OrObjectRecordFilter,
StringFilter,
URLFilter,
UUIDFilter,
} from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter';
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { isEmptyObject } from '~/utils/isEmptyObject';
export const isRecordMatchingFilter = ({
record,
filter,
objectMetadataItem,
}: {
record: any;
filter: ObjectRecordQueryFilter;
objectMetadataItem: ObjectMetadataItem;
}) => {
if (Object.keys(filter).length === 0) {
return true;
}
const currentLevelFilterMatches: boolean[] = [];
// We consider all the keys at the same level as an "and"
for (const filterKey in filter) {
if (filterKey === 'and') {
const filterValue = (filter as AndObjectRecordFilter).and;
if (!Array.isArray(filterValue)) {
throw new Error(
'Unexpected value for "and" filter : ' + JSON.stringify(filterValue),
);
}
if (filterValue.length === 0) {
currentLevelFilterMatches.push(true);
continue;
}
const recordIsMatchingAndFilters = filterValue.every((andFilter) =>
isRecordMatchingFilter({
record,
filter: andFilter,
objectMetadataItem,
}),
);
currentLevelFilterMatches.push(recordIsMatchingAndFilters);
} else if (filterKey === 'or') {
const filterValue = (filter as OrObjectRecordFilter).or;
if (Array.isArray(filterValue)) {
if (filterValue.length === 0) {
currentLevelFilterMatches.push(true);
continue;
}
const recordIsMatchingOrFilters = filterValue.some((orFilter) =>
isRecordMatchingFilter({
record,
filter: orFilter,
objectMetadataItem,
}),
);
currentLevelFilterMatches.push(recordIsMatchingOrFilters);
} else if (isObject(filterValue)) {
// The API considers "or" with an object as an "and"
const recordIsMatchingOrFilters = isRecordMatchingFilter({
record,
filter: filterValue,
objectMetadataItem,
});
currentLevelFilterMatches.push(recordIsMatchingOrFilters);
} else {
throw new Error('Unexpected value for "or" filter : ' + filterValue);
}
} else if (filterKey === 'not') {
const filterValue = (filter as NotObjectRecordFilter).not;
if (!isDefined(filterValue)) {
throw new Error('Unexpected value for "not" filter : ' + filterValue);
}
if (isEmptyObject(filterValue)) {
currentLevelFilterMatches.push(true);
continue;
}
const recordIsMatchingNotFilters = !isRecordMatchingFilter({
record,
filter: filterValue,
objectMetadataItem,
});
currentLevelFilterMatches.push(recordIsMatchingNotFilters);
} else {
const filterValue = (filter as LeafObjectRecordFilter)[filterKey];
if (!isDefined(filterValue)) {
throw new Error(
'Unexpected value for filter key "' +
filterKey +
'" : ' +
filterValue,
);
}
if (isEmptyObject(filterValue)) {
currentLevelFilterMatches.push(true);
continue;
}
const objectMetadataField = objectMetadataItem.fields.find(
(field) => field.name === filterKey,
);
if (!isDefined(objectMetadataField)) {
throw new Error(
'Field metadata item "' +
filterKey +
'" not found for object metadata item ' +
objectMetadataItem.nameSingular,
);
}
switch (objectMetadataField.type) {
case FieldMetadataType.Email:
case FieldMetadataType.Phone:
case FieldMetadataType.Text: {
const stringFilter = filterValue as StringFilter;
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Link: {
const urlFilter = filterValue as URLFilter;
if (urlFilter.url !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: urlFilter.url,
value: record[filterKey].url,
}),
);
}
if (urlFilter.label !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: urlFilter.label,
value: record[filterKey].label,
}),
);
}
break;
}
case FieldMetadataType.FullName: {
const fullNameFilter = filterValue as FullNameFilter;
if (fullNameFilter.firstName !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: fullNameFilter.firstName,
value: record[filterKey].firstName,
}),
);
}
if (fullNameFilter.lastName !== undefined) {
currentLevelFilterMatches.push(
isMatchingStringFilter({
stringFilter: fullNameFilter.lastName,
value: record[filterKey].lastName,
}),
);
}
break;
}
case FieldMetadataType.DateTime: {
const dateFilter = filterValue as DateFilter;
currentLevelFilterMatches.push(
isMatchingDateFilter({
dateFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Number:
case FieldMetadataType.Numeric: {
const numberFilter = filterValue as FloatFilter;
currentLevelFilterMatches.push(
isMatchingFloatFilter({
floatFilter: numberFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Uuid: {
const uuidFilter = filterValue as UUIDFilter;
currentLevelFilterMatches.push(
isMatchingUUIDFilter({
uuidFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Boolean: {
const booleanFilter = filterValue as BooleanFilter;
currentLevelFilterMatches.push(
isMatchingBooleanFilter({
booleanFilter,
value: record[filterKey],
}),
);
break;
}
case FieldMetadataType.Relation: {
throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
);
}
case FieldMetadataType.Currency:
case FieldMetadataType.MultiSelect:
case FieldMetadataType.Select:
case FieldMetadataType.Probability:
case FieldMetadataType.Rating: {
throw new Error('Not implemented yet');
}
}
}
}
return currentLevelFilterMatches.length > 0
? currentLevelFilterMatches.every((match) => !!match)
: false;
};

View File

@ -1,28 +1,31 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
CurrencyFilter,
DateFilter,
FloatFilter,
FullNameFilter,
ObjectRecordFilter,
ObjectRecordQueryFilter,
StringFilter,
URLFilter,
} from '@/object-record/types/ObjectRecordFilter';
UUIDFilter,
} from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
import { Filter } from '../object-filter-dropdown/types/Filter';
import { Filter } from '../../object-filter-dropdown/types/Filter';
export type RawUIFilter = Omit<Filter, 'definition'> & {
export type ObjectDropdownFilter = Omit<Filter, 'definition'> & {
definition: {
type: Filter['definition']['type'];
};
};
export const turnFiltersIntoObjectRecordFilters = (
rawUIFilters: RawUIFilter[],
export const turnObjectDropdownFilterIntoQueryFilter = (
rawUIFilters: ObjectDropdownFilter[],
fields: Pick<Field, 'id' | 'name'>[],
): ObjectRecordFilter => {
const objectRecordFilters: ObjectRecordFilter[] = [];
): ObjectRecordQueryFilter => {
const objectRecordFilters: ObjectRecordQueryFilter[] = [];
for (const rawUIFilter of rawUIFilters) {
const correspondingField = fields.find(
@ -107,6 +110,10 @@ export const turnFiltersIntoObjectRecordFilters = (
}
break;
case 'RELATION': {
if (!isNonEmptyString(rawUIFilter.value)) {
break;
}
try {
JSON.parse(rawUIFilter.value);
} catch (e) {
@ -123,7 +130,7 @@ export const turnFiltersIntoObjectRecordFilters = (
objectRecordFilters.push({
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as StringFilter,
} as UUIDFilter,
});
break;
case ViewFilterOperand.IsNot:
@ -131,7 +138,7 @@ export const turnFiltersIntoObjectRecordFilters = (
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as StringFilter,
} as UUIDFilter,
},
});
break;

View File

@ -0,0 +1,8 @@
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export type ObjectRecordQueryVariables = {
filter?: ObjectRecordQueryFilter;
orderBy?: OrderByField;
limit?: number;
};