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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2309 additions and 233 deletions

View File

@ -6,20 +6,18 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
type Task = Pick<Activity, 'id' | 'completedAt'>;
export const useCompleteTask = (task: Task) => {
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord({
const { updateOneRecord: updateOneActivity } = useUpdateOneRecord<Activity>({
objectNameSingular: 'activity',
refetchFindManyQuery: true,
});
const completeTask = useCallback(
(value: boolean) => {
async (value: boolean) => {
const completedAt = value ? new Date().toISOString() : null;
updateOneActivity?.({
await updateOneActivity?.({
idToUpdate: task.id,
input: {
completedAt,
},
forceRefetch: true,
});
},
[task.id, updateOneActivity],

View File

@ -1,21 +1,20 @@
import {
ApolloCache,
DocumentNode,
OperationVariables,
useApolloClient,
} from '@apollo/client';
import { useApolloClient } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { computeOptimisticEffectKey } from '@/apollo/optimistic-effect/utils/computeOptimisticEffectKey';
import {
EMPTY_QUERY,
useObjectMetadataItem,
} from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
import { optimisticEffectState } from '../states/optimisticEffectState';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
import {
OptimisticEffect,
OptimisticEffectWriter,
} from '../types/internal/OptimisticEffect';
import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition';
export const useOptimisticEffect = ({
@ -23,17 +22,41 @@ export const useOptimisticEffect = ({
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { findManyRecordsQuery } = useObjectMetadataItem({
const { findManyRecordsQuery, objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const registerOptimisticEffect = useRecoilCallback(
const unregisterOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
<T>({
({
variables,
definition,
}: {
variables: OperationVariables;
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const computedKey = computeOptimisticEffectKey({
variables,
definition,
});
const { [computedKey]: _, ...rest } = optimisticEffects;
set(optimisticEffectState, rest);
},
);
const registerOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
if (findManyRecordsQuery === EMPTY_QUERY) {
@ -46,21 +69,14 @@ export const useOptimisticEffect = ({
.getLoadable(optimisticEffectState)
.getValue();
const optimisticEffectWriter = ({
const optimisticEffectWriter: OptimisticEffectWriter = ({
cache,
newData,
createdRecords,
updatedRecords,
deletedRecordIds,
query,
variables,
objectMetadataItem,
}: {
cache: ApolloCache<unknown>;
newData: unknown;
deletedRecordIds?: string[];
variables: OperationVariables;
query: DocumentNode;
isUsingFlexibleBackend?: boolean;
objectMetadataItem?: ObjectMetadataItem;
}) => {
if (objectMetadataItem) {
const existingData = cache.readQuery({
@ -77,10 +93,11 @@ export const useOptimisticEffect = ({
variables,
data: {
[objectMetadataItem.namePlural]: definition.resolver({
currentData: (existingData as any)?.[
currentCacheData: (existingData as any)?.[
objectMetadataItem.namePlural
],
newData,
updatedRecords,
createdRecords,
deletedRecordIds,
variables,
}),
@ -91,7 +108,7 @@ export const useOptimisticEffect = ({
}
const existingData = cache.readQuery({
query,
query: query ?? findManyRecordsQuery,
variables,
});
@ -100,26 +117,40 @@ export const useOptimisticEffect = ({
}
};
const computedKey = computeOptimisticEffectKey({
variables,
definition,
});
const optimisticEffect = {
key: definition.key,
variables,
typename: definition.typename,
query: definition.query,
writer: optimisticEffectWriter,
objectMetadataItem: definition.objectMetadataItem,
isUsingFlexibleBackend: definition.isUsingFlexibleBackend,
} satisfies OptimisticEffect<T>;
objectMetadataItem,
} satisfies OptimisticEffect;
set(optimisticEffectState, {
...optimisticEffects,
[definition.key]: optimisticEffect,
[computedKey]: optimisticEffect,
});
},
[findManyRecordsQuery, objectNameSingular, objectMetadataItem],
);
const triggerOptimisticEffects = useRecoilCallback(
({ snapshot }) =>
(typename: string, newData: unknown, deletedRecordIds?: string[]) => {
({
typename,
createdRecords,
updatedRecords,
deletedRecordIds,
}: {
typename: string;
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
@ -127,20 +158,26 @@ export const useOptimisticEffect = ({
for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types
// It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedNewData = isNonEmptyArray(newData)
? newData.map((data: any) => {
const formattedCreatedRecords = isNonEmptyArray(createdRecords)
? createdRecords.map((data: any) => {
return { ...data, __typename: typename };
})
: newData;
: [];
const formattedUpdatedRecords = isNonEmptyArray(updatedRecords)
? updatedRecords.map((data: any) => {
return { ...data, __typename: typename };
})
: [];
if (optimisticEffect.typename === typename) {
optimisticEffect.writer({
cache: apolloClient.cache,
query: optimisticEffect.query ?? ({} as DocumentNode),
newData: formattedNewData,
query: optimisticEffect.query,
createdRecords: formattedCreatedRecords,
updatedRecords: formattedUpdatedRecords,
deletedRecordIds,
variables: optimisticEffect.variables,
isUsingFlexibleBackend: optimisticEffect.isUsingFlexibleBackend,
objectMetadataItem: optimisticEffect.objectMetadataItem,
});
}
@ -152,5 +189,6 @@ export const useOptimisticEffect = ({
return {
registerOptimisticEffect,
triggerOptimisticEffects,
unregisterOptimisticEffect,
};
};

View File

@ -2,9 +2,7 @@ import { atom } from 'recoil';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
export const optimisticEffectState = atom<
Record<string, OptimisticEffect<unknown>>
>({
export const optimisticEffectState = atom<Record<string, OptimisticEffect>>({
key: 'optimisticEffectState',
default: {},
});

View File

@ -5,10 +5,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OptimisticEffectResolver } from './OptimisticEffectResolver';
export type OptimisticEffectDefinition = {
key: string;
query?: DocumentNode;
typename: string;
resolver: OptimisticEffectResolver;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -1,13 +1,15 @@
import { OperationVariables } from '@apollo/client';
export type OptimisticEffectResolver = ({
currentData,
newData,
currentCacheData,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
}: {
currentData: any; //TODO: Change when decommissioning v1
newData: any; //TODO: Change when decommissioning v1
currentCacheData: any; //TODO: Change when decommissioning v1
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
variables: OperationVariables;
}) => void;

View File

@ -1,28 +1,30 @@
import { ApolloCache, DocumentNode, OperationVariables } from '@apollo/client';
import { ApolloCache, DocumentNode } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
type OptimisticEffectWriter<T> = ({
export type OptimisticEffectWriter = ({
cache,
newData,
variables,
query,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
objectMetadataItem,
}: {
cache: ApolloCache<T>;
query: DocumentNode;
newData: T;
cache: ApolloCache<any>;
query?: DocumentNode;
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
variables: OperationVariables;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
variables: ObjectRecordQueryVariables;
objectMetadataItem: ObjectMetadataItem;
}) => void;
export type OptimisticEffect<T> = {
key: string;
export type OptimisticEffect = {
query?: DocumentNode;
typename: string;
variables: OperationVariables;
writer: OptimisticEffectWriter<T>;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
variables: ObjectRecordQueryVariables;
writer: OptimisticEffectWriter;
objectMetadataItem: ObjectMetadataItem;
};

View File

@ -0,0 +1,17 @@
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const computeOptimisticEffectKey = ({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
const computedKey =
(definition.objectMetadataItem?.namePlural ?? definition.typename) +
'-' +
JSON.stringify(variables);
return computedKey;
};

View File

@ -9,7 +9,6 @@ import { Favorite } from '@/favorites/types/Favorite';
import { mapFavorites } from '@/favorites/utils/mapFavorites';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
@ -29,15 +28,13 @@ export const useFavorites = ({
updateOneRecordMutation: updateOneFavoriteMutation,
createOneRecordMutation: createOneFavoriteMutation,
deleteOneRecordMutation: deleteOneFavoriteMutation,
objectMetadataItem: favoriteObjectMetadataItem,
} = useObjectMetadataItem({
objectNameSingular: 'favorite',
});
const { registerOptimisticEffect, triggerOptimisticEffects } =
useOptimisticEffect({
objectNameSingular: 'favorite',
});
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular: 'favorite',
});
const { performOptimisticEvict } = useOptimisticEvict();
const { objectNameSingular } = useObjectNameSingularFromPlural({
@ -65,19 +62,8 @@ export const useFavorites = ({
if (!isDeeplyEqual(favorites, queriedFavorites)) {
set(favoritesState, queriedFavorites);
}
if (!favoriteObjectMetadataItem) {
return;
}
registerOptimisticEffect({
variables: { filter: {}, orderBy: {} },
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem: favoriteObjectMetadataItem,
}),
});
},
[favoriteObjectMetadataItem, registerOptimisticEffect],
[],
),
});
@ -102,7 +88,10 @@ export const useFavorites = ({
},
});
triggerOptimisticEffects(`FavoriteEdge`, result.data[`createFavorite`]);
triggerOptimisticEffects({
typename: `FavoriteEdge`,
createdRecords: [result.data[`createFavorite`]],
});
const createdFavorite = result?.data?.createFavorite;

View File

@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const useRecordOptimisticEffect = ({
objectMetadataItem,
filter,
orderBy,
limit,
}: {
objectMetadataItem: ObjectMetadataItem;
filter?: ObjectRecordQueryFilter;
orderBy?: OrderByField;
limit?: number;
}) => {
const { registerOptimisticEffect, unregisterOptimisticEffect } =
useOptimisticEffect({
objectNameSingular: objectMetadataItem.nameSingular,
});
useEffect(() => {
registerOptimisticEffect({
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem,
}),
variables: {
filter,
orderBy,
limit,
},
});
return () => {
unregisterOptimisticEffect({
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem,
}),
variables: {
filter,
orderBy,
limit,
},
});
};
}, [
registerOptimisticEffect,
filter,
orderBy,
limit,
objectMetadataItem,
unregisterOptimisticEffect,
]);
};

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;
};

View File

@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
@ -53,9 +54,11 @@ export const ViewBarFilterEffect = ({
filterDefinitionUsedInDropdown.fieldMetadataId,
);
const viewFilterSelectedRecordIds = JSON.parse(
viewFilterUsedInDropdown?.value ?? '[]',
);
const viewFilterSelectedRecordIds = isNonEmptyString(
viewFilterUsedInDropdown?.value,
)
? JSON.parse(viewFilterUsedInDropdown.value)
: [];
setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecordIds);
}

View File

@ -0,0 +1,417 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType, RelationMetadataType } from '~/generated/graphql';
export const mockObjectMetadataItem: ObjectMetadataItem = {
__typename: 'object',
id: 'b79a038c-b06b-4a5a-b7ee-f8ba412aa1c0',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
isSystem: false,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
fields: [
{
__typename: 'field',
id: '390eb5e5-d8d1-4064-bf75-3461251eb142',
type: FieldMetadataType.Boolean,
name: 'idealCustomerProfile',
label: 'ICP',
description:
'Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you',
icon: 'IconTarget',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: '72a43010-f236-4fa2-8ac4-a31e6b37d692',
type: FieldMetadataType.Relation,
name: 'people',
label: 'People',
description: 'People linked to the company.',
icon: 'IconUsers',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: {
id: 'f08943fe-e8a0-4747-951c-c3b391842453',
relationType: RelationMetadataType.OneToMany,
toObjectMetadata: {
id: 'fcccc985-5edf-405c-aa2b-80c82b230f35',
nameSingular: 'person',
namePlural: 'people',
},
toFieldMetadataId: 'c756f6ff-8c00-4fe5-a923-c6cfc7b1ac4a',
},
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: '51636fba-1bd9-4344-bba8-9639cbc8e134',
type: FieldMetadataType.Relation,
name: 'opportunities',
label: 'Opportunities',
description: 'Opportunities linked to the company.',
icon: 'IconTargetArrow',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: {
id: '7ffae8bb-b12b-4ad9-8922-da0d517b5612',
relationType: RelationMetadataType.OneToMany,
toObjectMetadata: {
id: '169e5b21-dc95-44a8-acd0-5e9447dd0784',
nameSingular: 'opportunity',
namePlural: 'opportunities',
},
toFieldMetadataId: '00468e2a-a601-4635-ae9c-a9bb826cc860',
},
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'd541f76b-d327-4dda-8ef8-81b60e5ad01e',
type: FieldMetadataType.Relation,
name: 'activityTargets',
label: 'Activities',
description: 'Activities tied to the company',
icon: 'IconCheckbox',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: {
id: 'bc42672b-350f-45c3-bd1f-4debb536ccd1',
relationType: RelationMetadataType.OneToMany,
toObjectMetadata: {
id: 'b87c6cac-a8e7-4156-a525-30ec536acd75',
nameSingular: 'activityTarget',
namePlural: 'activityTargets',
},
toFieldMetadataId: 'bba19feb-c248-487b-92d7-98df54c51e44',
},
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'dacb7562-497e-4080-8ef5-746d6786ed49',
type: FieldMetadataType.DateTime,
name: 'createdAt',
label: 'Creation date',
description: null,
icon: 'IconCalendar',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
type: 'now',
},
},
{
__typename: 'field',
id: 'f3b4ff22-800b-4f13-8262-8003da8eed5b',
type: FieldMetadataType.Number,
name: 'employees',
label: 'Employees',
description: 'Number of employees in the company',
icon: 'IconUsers',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'c3e64012-32cc-43f1-af2f-33b37cc4e59d',
type: FieldMetadataType.Link,
name: 'linkedinLink',
label: 'Linkedin',
description: 'The company Linkedin account',
icon: 'IconBrandLinkedin',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'fced9acc-0374-487d-9da4-579a17435df0',
type: FieldMetadataType.Link,
name: 'xLink',
label: 'X',
description: 'The company Twitter/X account',
icon: 'IconBrandX',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: '63db0a2f-ffb4-4ea1-98c7-f7e13ce75c38',
type: FieldMetadataType.Relation,
name: 'attachments',
label: 'Attachments',
description: 'Attachments linked to the company.',
icon: 'IconFileImport',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: {
id: '901fd405-c6bf-4559-9d1f-d0937b6f16d9',
relationType: RelationMetadataType.OneToMany,
toObjectMetadata: {
id: '77240b4b-6bcf-454d-a102-19bbba181716',
nameSingular: 'attachment',
namePlural: 'attachments',
},
toFieldMetadataId: '0880dac5-37d2-43a6-b143-722126d4923f',
},
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'e775ce12-87c0-4feb-bcfe-9af3d8ca117b',
type: FieldMetadataType.Uuid,
name: 'id',
label: 'Id',
description: null,
icon: null,
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
type: 'uuid',
},
},
{
__typename: 'field',
id: '2278ef91-3d6a-45cf-86f5-76b7bfa2bf32',
type: FieldMetadataType.Text,
name: 'domainName',
label: 'Domain Name',
description:
'The company website URL. We use this url to fetch the company icon',
icon: 'IconLink',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
value: '',
},
},
{
__typename: 'field',
id: '438291d7-18f4-48cf-8dca-05e96c5a0765',
type: FieldMetadataType.Currency,
name: 'annualRecurringRevenue',
label: 'ARR',
description:
'Annual Recurring Revenue: The actual or estimated annual revenue of the company',
icon: 'IconMoneybag',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'edb8475f-03fc-4ac1-9305-e9d4e2dacd11',
type: FieldMetadataType.DateTime,
name: 'updatedAt',
label: 'Update date',
description: null,
icon: 'IconCalendar',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
type: 'now',
},
},
{
__typename: 'field',
id: 'e3c9ba7f-cecf-4ac6-a7b9-7a9987be0253',
type: FieldMetadataType.Relation,
name: 'accountOwner',
label: 'Account Owner',
description:
'Your team member responsible for managing the company account',
icon: 'IconUserCircle',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: {
id: '0317d74c-5187-491f-9e1d-d22f06ca2a38',
relationType: RelationMetadataType.OneToMany,
fromObjectMetadata: {
id: '92c306ce-ad06-4712-99d2-5d0daf13c95f',
nameSingular: 'workspaceMember',
namePlural: 'workspaceMembers',
},
fromFieldMetadataId: '0f3e456f-3bb4-4261-a436-95246dc0e159',
},
defaultValue: null,
},
{
__typename: 'field',
id: 'a34bd3b3-6949-4793-bac6-d2c054639c7f',
type: FieldMetadataType.Text,
name: 'address',
label: 'Address',
description: 'The company address',
icon: 'IconMap',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
value: '',
},
},
{
__typename: 'field',
id: '4b204845-f1fc-4fd8-8fdd-f4caeaab749f',
type: FieldMetadataType.Relation,
name: 'favorites',
label: 'Favorites',
description: 'Favorites linked to the company',
icon: 'IconHeart',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: {
id: '8e0d3aa1-6135-4d65-aa28-15a5b6d1619c',
relationType: RelationMetadataType.OneToMany,
toObjectMetadata: {
id: '1415392e-0ecb-462e-aa67-001e424e6a37',
nameSingular: 'favorite',
namePlural: 'favorites',
},
toFieldMetadataId: '8fd8965b-bd4e-4a9b-90e9-c75652dadda1',
},
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: 'a795e81e-0bcf-4fd6-8f2f-b3764b990d2d',
type: FieldMetadataType.Uuid,
name: 'accountOwnerId',
label: 'Account Owner id (foreign key)',
description:
'Your team member responsible for managing the company account id foreign key',
icon: 'IconUserCircle',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
},
{
__typename: 'field',
id: '87887d23-f632-4d3e-840a-02fcee960660',
type: FieldMetadataType.Text,
name: 'name',
label: 'Name',
description: 'The company name',
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2023-12-19T12:15:28.459Z',
updatedAt: '2023-12-19T12:15:28.459Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
value: '',
},
},
],
};

View File

@ -0,0 +1,3 @@
export const isAnObject = (obj: any): obj is object => {
return typeof obj === 'object' && obj !== null && Object.keys(obj).length > 0;
};

View File

@ -0,0 +1,5 @@
import { isObject } from '@sniptt/guards';
export const isEmptyObject = (obj: any): obj is object => {
return isObject(obj) && Object.keys(obj).length === 0;
};