[CoreViewField BREAKING_CHANGES] Refactor view field service v2 and resolver (#14396)

## Introduction

### Twenty-sever

Standardizing resolver input and transpilation models + return type on
destroy and delete
~~Finally~~ Did plug everything under a feature flag and add coverage
Next will do same for the view resolver and service v2

### Twenty-front
Refactored view field service in order to use codegenerated strictly
typed mutations and adapt to new api contract
This commit is contained in:
Paul Rastoin
2025-09-11 17:25:36 +02:00
committed by GitHub
parent d9a6e7e0b1
commit c8bfbf00d7
40 changed files with 940 additions and 397 deletions
@@ -32,11 +32,18 @@ export const useViewFieldAggregateOperation = () => {
}
await updateViewFieldRecords([
{
...currentViewField,
aggregateOperation:
convertExtendedAggregateOperationToAggregateOperation(
aggregateOperation,
),
input: {
id: currentViewField.id,
update: {
isVisible: currentViewField.isVisible,
position: currentViewField.position,
size: currentViewField.size,
aggregateOperation:
convertExtendedAggregateOperationToAggregateOperation(
aggregateOperation,
),
},
},
},
]);
@@ -9,5 +9,8 @@ export const VIEW_FIELD_FRAGMENT = gql`
position
size
aggregateOperation
createdAt
updatedAt
deletedAt
}
`;
@@ -1,7 +1,11 @@
import { VIEW_FIELD_FRAGMENT } from '@/views/graphql/fragments/viewFieldFragment';
import { gql } from '@apollo/client';
export const DELETE_CORE_VIEW_FIELD = gql`
mutation DeleteCoreViewField($id: String!) {
deleteCoreViewField(id: $id)
${VIEW_FIELD_FRAGMENT}
mutation DeleteCoreViewField($input: DeleteViewFieldInput!) {
deleteCoreViewField(input: $input) {
...ViewFieldFragment
}
}
`;
@@ -1,7 +1,11 @@
import { VIEW_FIELD_FRAGMENT } from '@/views/graphql/fragments/viewFieldFragment';
import { gql } from '@apollo/client';
export const DESTROY_CORE_VIEW_FIELD = gql`
mutation DestroyCoreViewField($id: String!) {
destroyCoreViewField(id: $id)
${VIEW_FIELD_FRAGMENT}
mutation DestroyCoreViewField($input: DestroyViewFieldInput!) {
destroyCoreViewField(input: $input) {
...ViewFieldFragment
}
}
`;
@@ -3,8 +3,8 @@ import { gql } from '@apollo/client';
export const UPDATE_CORE_VIEW_FIELD = gql`
${VIEW_FIELD_FRAGMENT}
mutation UpdateCoreViewField($id: String!, $input: UpdateViewFieldInput!) {
updateCoreViewField(id: $id, input: $input) {
mutation UpdateCoreViewField($input: UpdateViewFieldInput!) {
updateCoreViewField(input: $input) {
...ViewFieldFragment
}
}
@@ -1,90 +1,128 @@
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { CREATE_CORE_VIEW_FIELD } from '@/views/graphql/mutations/createCoreViewField';
import { UPDATE_CORE_VIEW_FIELD } from '@/views/graphql/mutations/updateCoreViewField';
import { useTriggerViewFieldOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewFieldOptimisticEffect';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type ViewField } from '@/views/types/ViewField';
import { useApolloClient } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { type CoreViewField } from '~/generated/graphql';
import {
type CreateCoreViewFieldMutationVariables,
type DeleteCoreViewFieldMutationVariables,
type DestroyCoreViewFieldMutationVariables,
type UpdateCoreViewFieldMutationVariables,
useCreateCoreViewFieldMutation,
useDeleteCoreViewFieldMutation,
useDestroyCoreViewFieldMutation,
useUpdateCoreViewFieldMutation,
} from '~/generated/graphql';
export const usePersistViewFieldRecords = () => {
const apolloClient = useApolloClient();
const { triggerViewFieldOptimisticEffect } =
useTriggerViewFieldOptimisticEffect();
const [createCoreViewFieldMutation] = useCreateCoreViewFieldMutation();
const [updateCoreViewFieldMutation] = useUpdateCoreViewFieldMutation();
const [deleteCoreViewFieldMutation] = useDeleteCoreViewFieldMutation();
const [destroyCoreViewFieldMutation] = useDestroyCoreViewFieldMutation();
const createCoreViewFieldRecords = useCallback(
(
viewFieldsToCreate: Omit<ViewField, 'definition'>[],
view: Pick<GraphQLView, 'id'>,
) => {
if (!viewFieldsToCreate.length) return;
(createCoreViewFieldInputs: CreateCoreViewFieldMutationVariables[]) => {
if (createCoreViewFieldInputs.length === 0) {
return;
}
return Promise.all(
viewFieldsToCreate.map((viewField) =>
apolloClient.mutate({
mutation: CREATE_CORE_VIEW_FIELD,
variables: {
input: {
id: v4(),
fieldMetadataId: viewField.fieldMetadataId,
viewId: view.id,
isVisible: viewField.isVisible,
position: viewField.position,
size: viewField.size,
} satisfies Partial<CoreViewField>,
},
createCoreViewFieldInputs.map(async (variables) =>
createCoreViewFieldMutation({
variables,
update: (_cache, { data }) => {
const record = data?.['createCoreViewField'];
if (!record) return;
const createdViewField = data?.createCoreViewField;
if (!isDefined(createdViewField)) {
return;
}
triggerViewFieldOptimisticEffect({
createdViewFields: [record],
createdViewFields: [createdViewField],
});
},
}),
),
);
},
[apolloClient, triggerViewFieldOptimisticEffect],
[triggerViewFieldOptimisticEffect, createCoreViewFieldMutation],
);
const updateCoreViewFieldRecords = useCallback(
(viewFieldsToUpdate: Omit<ViewField, 'definition'>[]) => {
if (!viewFieldsToUpdate.length) return;
(createCoreViewFieldInputs: UpdateCoreViewFieldMutationVariables[]) => {
if (createCoreViewFieldInputs.length === 0) {
return;
}
return Promise.all(
viewFieldsToUpdate.map((viewField) =>
apolloClient.mutate({
mutation: UPDATE_CORE_VIEW_FIELD,
variables: {
id: viewField.id,
input: {
isVisible: viewField.isVisible,
position: viewField.position,
size: viewField.size,
aggregateOperation: viewField.aggregateOperation,
} satisfies Partial<CoreViewField>,
},
createCoreViewFieldInputs.map((variables) =>
updateCoreViewFieldMutation({
variables,
update: (_cache, { data }) => {
const record = data?.['updateCoreViewField'];
if (!isDefined(record)) return;
const updatedViewField = data?.updateCoreViewField;
if (!isDefined(updatedViewField)) {
return;
}
triggerViewFieldOptimisticEffect({
updatedViewFields: [record],
updatedViewFields: [updatedViewField],
});
},
}),
),
);
},
[apolloClient, triggerViewFieldOptimisticEffect],
[triggerViewFieldOptimisticEffect, updateCoreViewFieldMutation],
);
const deleteCoreViewFieldRecords = useCallback(
(deleteCoreViewFieldInputs: DeleteCoreViewFieldMutationVariables[]) => {
if (deleteCoreViewFieldInputs.length === 0) {
return;
}
return Promise.all(
deleteCoreViewFieldInputs.map((variables) =>
deleteCoreViewFieldMutation({
variables,
update: (_cache, { data }) => {
const deletedViewField = data?.deleteCoreViewField;
if (!isDefined(deletedViewField)) {
return;
}
triggerViewFieldOptimisticEffect({
deletedViewFields: [deletedViewField],
});
},
}),
),
);
},
[triggerViewFieldOptimisticEffect, deleteCoreViewFieldMutation],
);
const destroyCoreViewFieldRecords = useCallback(
(destroyCoreViewFieldInputs: DestroyCoreViewFieldMutationVariables[]) => {
if (destroyCoreViewFieldInputs.length === 0) {
return;
}
return Promise.all(
destroyCoreViewFieldInputs.map((variables) =>
destroyCoreViewFieldMutation({
variables,
}),
),
);
},
[destroyCoreViewFieldMutation],
);
return {
createViewFieldRecords: createCoreViewFieldRecords,
updateViewFieldRecords: updateCoreViewFieldRecords,
deleteViewFieldRecords: deleteCoreViewFieldRecords,
destroyViewFieldRecords: destroyCoreViewFieldRecords,
};
};
@@ -28,7 +28,10 @@ import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { useCreateCoreViewMutation } from '~/generated/graphql';
import {
type CreateCoreViewFieldMutationVariables,
useCreateCoreViewMutation,
} from '~/generated/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
@@ -138,7 +141,13 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
throw new Error('Failed to create view');
}
await createViewFieldRecords(sourceView.viewFields, { id: newViewId });
await createViewFieldRecords(
sourceView.viewFields.map<CreateCoreViewFieldMutationVariables>(
({ __typename, ...viewField }) => ({
input: { ...viewField, viewId: newViewId },
}),
),
);
if (type === ViewType.Kanban) {
if (!isDefined(kanbanFieldMetadataId)) {
@@ -11,7 +11,7 @@ import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordFie
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
import { useFindManyCoreViewsLazyQuery } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
@@ -71,7 +71,17 @@ export const useRefreshCoreViewsByObjectMetadataId = () => {
continue;
}
if (!isDeeplyEqual(coreView.viewFields, existingView.viewFields)) {
if (
!isDeeplyEqual(
coreView.viewFields.map((viewField) =>
removePropertiesFromRecord(viewField, [
'updatedAt',
'createdAt',
]),
),
existingView.viewFields,
)
) {
const view = convertCoreViewToView(coreView);
set(
currentRecordFieldsComponentState.atomFamily({
@@ -6,7 +6,11 @@ import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistVie
import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState';
import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState';
import { type ViewField } from '@/views/types/ViewField';
import { isDefined } from 'twenty-shared/utils';
import {
type CreateCoreViewFieldMutationVariables,
type CreateViewFieldInput,
type UpdateCoreViewFieldMutationVariables,
} from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -41,50 +45,82 @@ export const useSaveCurrentViewFields = () => {
const currentViewFields = view.viewFields;
const viewFieldsToUpdate = viewFieldsToSave
.map((viewFieldToSave) => {
const existingField = currentViewFields.find(
(currentViewField) =>
currentViewField.fieldMetadataId ===
viewFieldToSave.fieldMetadataId,
);
const { viewFieldsToCreate, viewFieldsToUpdate } =
viewFieldsToSave.reduce<{
viewFieldsToCreate: CreateCoreViewFieldMutationVariables[];
viewFieldsToUpdate: UpdateCoreViewFieldMutationVariables[];
}>(
(
{ viewFieldsToCreate, viewFieldsToUpdate },
{ __typename, ...viewFieldToCreateOrUpdate },
) => {
const createViewFieldInput: CreateViewFieldInput = {
...viewFieldToCreateOrUpdate,
viewId: currentViewId,
};
const existingField = currentViewFields.find(
(currentViewField) =>
currentViewField.fieldMetadataId ===
createViewFieldInput.fieldMetadataId,
);
if (isUndefinedOrNull(existingField)) {
return undefined;
}
if (isUndefinedOrNull(existingField)) {
return {
viewFieldsToCreate: [
...viewFieldsToCreate,
{ input: createViewFieldInput },
],
viewFieldsToUpdate,
};
}
if (
isDeeplyEqual(
{
position: existingField.position,
size: existingField.size,
isVisible: existingField.isVisible,
},
{
position: viewFieldToSave.position,
size: viewFieldToSave.size,
isVisible: viewFieldToSave.isVisible,
},
)
) {
return undefined;
}
if (
isDeeplyEqual(
{
position: existingField.position,
size: existingField.size,
isVisible: existingField.isVisible,
},
{
position: createViewFieldInput.position,
size: createViewFieldInput.size,
isVisible: createViewFieldInput.isVisible,
},
)
) {
return {
viewFieldsToCreate,
viewFieldsToUpdate,
};
}
return { ...viewFieldToSave, id: existingField.id };
})
.filter(isDefined);
const viewFieldsToCreate = viewFieldsToSave.filter(
(viewFieldToSave) =>
!currentViewFields.some(
(currentViewField) =>
currentViewField.fieldMetadataId ===
viewFieldToSave.fieldMetadataId,
),
);
return {
viewFieldsToCreate,
viewFieldsToUpdate: [
...viewFieldsToUpdate,
{
input: {
id: createViewFieldInput.id,
update: {
aggregateOperation:
createViewFieldInput.aggregateOperation,
isVisible: createViewFieldInput.isVisible,
position: createViewFieldInput.position,
size: createViewFieldInput.size,
},
},
},
],
};
},
{
viewFieldsToUpdate: [],
viewFieldsToCreate: [],
},
);
await Promise.all([
createViewFieldRecords(viewFieldsToCreate, view),
createViewFieldRecords(viewFieldsToCreate),
updateViewFieldRecords(viewFieldsToUpdate),
]);
@@ -19,8 +19,8 @@ export const useTriggerViewFieldOptimisticEffect = () => {
updatedViewFields = [],
deletedViewFields = [],
}: {
createdViewFields?: CoreViewField[];
updatedViewFields?: CoreViewField[];
createdViewFields?: Omit<CoreViewField, 'workspaceId'>[];
updatedViewFields?: Omit<CoreViewField, 'workspaceId'>[];
deletedViewFields?: Pick<CoreViewField, 'id' | 'viewId'>[];
}) => {
const coreViews = getSnapshotValue(snapshot, coreViewsState);