[GQL_VIEW_FILTER_API_BREAKING_CHANGE][WHEN_RELEASED_REQUIRES_CACHE_FLUSH] ViewFilter migration to workspace migration v2 (#15010)

# Introduction
Migrating `viewFilter` to v2 in order to migrate later the field update
side effect on view to v2 too

## What's done
- Created flat-view-filter
- flat view filter runner
- flat view filter builder
- create view filter service v2 and input transpilers
- refactor the existing view filter resolver to fix standard (
BREAKING_CHANGE on graphql api update especially ) REST stays the same
- refactored the front to consume the mutations autogenerated

## New generic tools
### Compare two flat entity
Introducing a new util to compare two flat entity, it's strictly typed
and will be added to the generic builder in a following PR
This will ease flat entity addition as won't required to create a
specific abstraction for comparison
Generic builder will expect specific constant: properties to compare and
properties to stringify

### Transform flat entity for comparison
Forked and refactor the initial existing method for flat entity business
scope and type safety

## Coverage
Migrated existing integration tests to fit new contract API
This PR does not add strong coverage on validation exceptions
Deadlines are too short

close https://github.com/twentyhq/core-team-issues/issues/1666
This commit is contained in:
Paul Rastoin
2025-10-10 15:02:14 +02:00
committed by GitHub
parent 37473175a0
commit 651ab184a7
97 changed files with 3012 additions and 620 deletions
@@ -10,5 +10,8 @@ export const VIEW_FILTER_FRAGMENT = gql`
positionInViewFilterGroup
subFieldName
viewId
createdAt
updatedAt
deletedAt
}
`;
@@ -1,7 +1,11 @@
import { VIEW_FILTER_FRAGMENT } from '@/views/graphql/fragments/viewFilterFragment';
import { gql } from '@apollo/client';
export const DELETE_CORE_VIEW_FILTER = gql`
mutation DeleteCoreViewFilter($id: String!) {
deleteCoreViewFilter(id: $id)
${VIEW_FILTER_FRAGMENT}
mutation DeleteCoreViewFilter($input: DeleteViewFilterInput!) {
deleteCoreViewFilter(input: $input) {
...ViewFilterFragment
}
}
`;
@@ -1,7 +1,11 @@
import { VIEW_FILTER_FRAGMENT } from '@/views/graphql/fragments/viewFilterFragment';
import { gql } from '@apollo/client';
export const DESTROY_CORE_VIEW_FILTER = gql`
mutation DestroyCoreViewFilter($id: String!) {
destroyCoreViewFilter(id: $id)
${VIEW_FILTER_FRAGMENT}
mutation DestroyCoreViewFilter($input: DestroyViewFilterInput!) {
destroyCoreViewFilter(input: $input) {
...ViewFilterFragment
}
}
`;
@@ -3,8 +3,8 @@ import { gql } from '@apollo/client';
export const UPDATE_CORE_VIEW_FILTER = gql`
${VIEW_FILTER_FRAGMENT}
mutation UpdateCoreViewFilter($id: String!, $input: UpdateViewFilterInput!) {
updateCoreViewFilter(id: $id, input: $input) {
mutation UpdateCoreViewFilter($input: UpdateViewFilterInput!) {
updateCoreViewFilter(input: $input) {
...ViewFilterFragment
}
}
@@ -1,159 +1,128 @@
import { useCallback } from 'react';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { CREATE_CORE_VIEW_FILTER } from '@/views/graphql/mutations/createCoreViewFilter';
import { DESTROY_CORE_VIEW_FILTER } from '@/views/graphql/mutations/destroyCoreViewFilter';
import { UPDATE_CORE_VIEW_FILTER } from '@/views/graphql/mutations/updateCoreViewFilter';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { useApolloClient } from '@apollo/client';
import { isNull } from '@sniptt/guards';
import { useTriggerViewFilterOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewFilterOptimisticEffect';
import { isDefined } from 'twenty-shared/utils';
import { type CoreViewFilter } from '~/generated/graphql';
import {
type CreateCoreViewFilterMutationVariables,
type DeleteCoreViewFilterMutationVariables,
type DestroyCoreViewFilterMutationVariables,
type UpdateCoreViewFilterMutationVariables,
useCreateCoreViewFilterMutation,
useDeleteCoreViewFilterMutation,
useDestroyCoreViewFilterMutation,
useUpdateCoreViewFilterMutation,
} from '~/generated/graphql';
export const usePersistViewFilterRecords = () => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ViewFilter,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.ViewFilter,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const apolloClient = useApolloClient();
const { triggerViewFilterOptimisticEffect } =
useTriggerViewFilterOptimisticEffect();
const [createCoreViewFilterMutation] = useCreateCoreViewFilterMutation();
const [updateCoreViewFilterMutation] = useUpdateCoreViewFilterMutation();
const [deleteCoreViewFilterMutation] = useDeleteCoreViewFilterMutation();
const [destroyCoreViewFilterMutation] = useDestroyCoreViewFilterMutation();
const createCoreViewFilterRecords = useCallback(
(viewFiltersToCreate: ViewFilter[], view: Pick<GraphQLView, 'id'>) => {
if (viewFiltersToCreate.length === 0) return;
(createCoreViewFilterInputs: CreateCoreViewFilterMutationVariables[]) => {
if (createCoreViewFilterInputs.length === 0) {
return;
}
return Promise.all(
viewFiltersToCreate.map((viewFilter) =>
apolloClient.mutate({
mutation: CREATE_CORE_VIEW_FILTER,
variables: {
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: view.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
} satisfies Partial<CoreViewFilter>,
},
update: (cache, { data }) => {
const record = data?.['createCoreViewFilter'];
if (!isDefined(record)) return;
createCoreViewFilterInputs.map((variables) =>
createCoreViewFilterMutation({
variables,
update: (_cache, { data }) => {
const createdViewFilter = data?.createCoreViewFilter;
if (!isDefined(createdViewFilter)) {
return;
}
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
objectPermissionsByObjectMetadataId,
triggerViewFilterOptimisticEffect({
createdViewFilters: [createdViewFilter],
});
},
}),
),
);
},
[
apolloClient,
objectMetadataItem,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
],
[triggerViewFilterOptimisticEffect, createCoreViewFilterMutation],
);
const updateCoreViewFilterRecords = useCallback(
(viewFiltersToUpdate: ViewFilter[]) => {
if (!viewFiltersToUpdate.length) return;
(updateCoreViewFilterInputs: UpdateCoreViewFilterMutationVariables[]) => {
if (updateCoreViewFilterInputs.length === 0) {
return;
}
return Promise.all(
viewFiltersToUpdate.map((viewFilter) =>
apolloClient.mutate({
mutation: UPDATE_CORE_VIEW_FILTER,
variables: {
id: viewFilter.id,
input: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
} satisfies Partial<CoreViewFilter>,
},
update: (cache, { data }) => {
const record = data?.['updateCoreViewFilter'];
if (!isDefined(record)) return;
updateCoreViewFilterInputs.map((variables) =>
updateCoreViewFilterMutation({
variables,
update: (_cache, { data }) => {
const updatedViewFilter = data?.updateCoreViewFilter;
if (!isDefined(updatedViewFilter)) {
return;
}
const cachedRecord = getRecordFromCache<ViewFilter>(
record.id,
cache,
);
if (isNull(cachedRecord)) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: cachedRecord,
updatedRecord: record,
objectMetadataItems,
triggerViewFilterOptimisticEffect({
updatedViewFilters: [updatedViewFilter],
});
},
}),
),
);
},
[apolloClient, getRecordFromCache, objectMetadataItem, objectMetadataItems],
[triggerViewFilterOptimisticEffect, updateCoreViewFilterMutation],
);
const deleteCoreViewFilterRecords = useCallback(
(viewFilterIdsToDelete: string[]) => {
if (!viewFilterIdsToDelete.length) return;
(deleteCoreViewFilterInputs: DeleteCoreViewFilterMutationVariables[]) => {
if (deleteCoreViewFilterInputs.length === 0) {
return;
}
return Promise.all(
viewFilterIdsToDelete.map((viewFilterId) =>
apolloClient.mutate({
mutation: DESTROY_CORE_VIEW_FILTER,
variables: {
id: viewFilterId,
},
update: (cache, { data }) => {
const record = data?.['destroyCoreViewFilter'];
if (!isDefined(record)) return;
deleteCoreViewFilterInputs.map((variables) =>
deleteCoreViewFilterMutation({
variables,
update: (_cache, { data }) => {
const deletedViewFilter = data?.deleteCoreViewFilter;
if (!isDefined(deletedViewFilter)) {
return;
}
const cachedRecord = getRecordFromCache<ViewFilter>(
record.id,
cache,
);
if (isNull(cachedRecord)) return;
triggerDestroyRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToDestroy: [cachedRecord],
objectMetadataItems,
triggerViewFilterOptimisticEffect({
deletedViewFilters: [deletedViewFilter],
});
},
}),
),
);
},
[apolloClient, getRecordFromCache, objectMetadataItem, objectMetadataItems],
[triggerViewFilterOptimisticEffect, deleteCoreViewFilterMutation],
);
const destroyCoreViewFilterRecords = useCallback(
(destroyCoreViewFilterInputs: DestroyCoreViewFilterMutationVariables[]) => {
if (destroyCoreViewFilterInputs.length === 0) {
return;
}
return Promise.all(
destroyCoreViewFilterInputs.map((variables) =>
destroyCoreViewFilterMutation({
variables,
}),
),
);
},
[destroyCoreViewFilterMutation],
);
return {
createViewFilterRecords: createCoreViewFilterRecords,
updateViewFilterRecords: updateCoreViewFilterRecords,
deleteViewFilterRecords: deleteCoreViewFilterRecords,
destroyViewFilterRecords: destroyCoreViewFilterRecords,
};
};
@@ -231,7 +231,21 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
await createViewFilterGroupRecords(viewFilterGroupsToCreate, {
id: newViewId,
});
await createViewFilterRecords(viewFiltersToCreate, { id: newViewId });
const createViewFilterInputs = viewFiltersToCreate.map(
(viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: newViewId,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
}),
);
await createViewFilterRecords(createViewFilterInputs);
await createViewSortRecords(viewSortsToCreate, { id: newViewId });
}
@@ -95,7 +95,17 @@ export const useRefreshCoreViewsByObjectMetadataId = () => {
);
}
if (!isDeeplyEqual(coreView.viewFilters, existingView.viewFilters)) {
if (
!isDeeplyEqual(
coreView.viewFilters.map((viewFilter) =>
removePropertiesFromRecord(viewFilter, [
'createdAt',
'updatedAt',
]),
),
existingView.viewFilters,
)
) {
const view = convertCoreViewToView(coreView);
set(
currentRecordFiltersComponentState.atomFamily({
@@ -56,13 +56,47 @@ export const useSaveRecordFiltersToViewFilters = () => {
newViewFilters,
);
const viewFilterIdsToDelete = viewFiltersToDelete.map(
(viewFilter) => viewFilter.id,
const createViewFilterInputs = viewFiltersToCreate.map(
(viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: currentView.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
}),
);
await createViewFilterRecords(viewFiltersToCreate, currentView);
await updateViewFilterRecords(viewFiltersToUpdate);
await deleteViewFilterRecords(viewFilterIdsToDelete);
const updateViewFilterInputs = viewFiltersToUpdate.map(
(viewFilter) => ({
input: {
id: viewFilter.id,
update: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
},
},
}),
);
const deleteViewFilterInputs = viewFiltersToDelete.map(
(viewFilter) => ({
input: {
id: viewFilter.id,
},
}),
);
await createViewFilterRecords(createViewFilterInputs);
await updateViewFilterRecords(updateViewFilterInputs);
await deleteViewFilterRecords(deleteViewFilterInputs);
},
[
currentView,
@@ -0,0 +1,144 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
import { useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type CoreViewFilter } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type UpdatedDeletedCoreViewFilter = {
createdViewFilters?: Omit<CoreViewFilter, 'workspaceId'>[];
updatedViewFilters?: Omit<CoreViewFilter, 'workspaceId'>[];
deletedViewFilters?: Pick<CoreViewFilter, 'id' | 'viewId'>[];
};
export const useTriggerViewFilterOptimisticEffect = () => {
const apolloClient = useApolloClient();
const cache = apolloClient.cache;
const triggerViewFilterOptimisticEffect = useRecoilCallback(
({ set, snapshot }) =>
({
createdViewFilters = [],
updatedViewFilters = [],
deletedViewFilters = [],
}: UpdatedDeletedCoreViewFilter) => {
const coreViews = getSnapshotValue(snapshot, coreViewsState);
let newCoreViews = [...coreViews];
createdViewFilters.forEach((createdViewFilter) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: createdViewFilter.viewId,
}),
fields: {
viewFilters: (existingViewFilters, { toReference }) => [
...(existingViewFilters ?? []),
toReference(createdViewFilter),
],
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === createdViewFilter.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== createdViewFilter.viewId,
),
{
...toBeModifiedCoreView,
viewFilters: [
...toBeModifiedCoreView.viewFilters,
createdViewFilter,
],
},
];
}
});
updatedViewFilters.forEach((updatedViewFilter) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: updatedViewFilter.viewId,
}),
fields: {
viewFilters: (existingViewFilters, { readField, toReference }) =>
existingViewFilters.map((viewFilter) => {
const viewFilterId = readField<string>('id', viewFilter);
if (viewFilterId === updatedViewFilter.id) {
return toReference(updatedViewFilter);
}
return viewFilter;
}),
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === updatedViewFilter.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== updatedViewFilter.viewId,
),
{
...toBeModifiedCoreView,
viewFilters: [
...toBeModifiedCoreView.viewFilters.filter(
(viewFilter) => viewFilter.id !== updatedViewFilter.id,
),
updatedViewFilter,
],
},
];
}
});
deletedViewFilters.forEach(
(deletedViewFilter: Pick<CoreViewFilter, 'id' | 'viewId'>) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: deletedViewFilter.viewId,
}),
fields: {
viewFilters: (existingViewFilters, { readField }) =>
existingViewFilters.filter(
(viewFilter) =>
readField('id', viewFilter) !== deletedViewFilter.id,
),
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === deletedViewFilter.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== deletedViewFilter.viewId,
),
{
...toBeModifiedCoreView,
viewFilters: toBeModifiedCoreView.viewFilters.filter(
(viewFilter) => viewFilter.id !== deletedViewFilter.id,
),
},
];
}
},
);
if (!isDeeplyEqual(coreViews, newCoreViews)) {
set(coreViewsState, newCoreViews);
}
},
[cache],
);
return {
triggerViewFilterOptimisticEffect,
};
};