fix: when field metadata SELECT type is edited update view groups (#8344)

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-11-06 11:41:44 +01:00
committed by GitHub
parent e36363fe15
commit ac7d740135
11 changed files with 304 additions and 28 deletions

View File

@@ -21,6 +21,7 @@ const baseFields = `
settings
`;
export const queries = {
deleteMetadataField: gql`
mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {
@@ -29,6 +30,37 @@ export const queries = {
}
}
`,
findManyViewsQuery: gql`
query FindManyViews($filter: ViewFilterInput, $orderBy: [ViewOrderByInput], $lastCursor: String, $limit: Int) {
views(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor) {
edges {
node {
__typename
id
viewGroups {
edges {
node {
__typename
fieldMetadataId
fieldValue
id
isVisible
position
}
}
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}`,
deleteMetadataFieldRelation: gql`
mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {
deleteOneRelation(input: { id: $idToDelete }) {

View File

@@ -1,12 +1,11 @@
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { act, ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { act } from 'react';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType, RelationDefinitionType } from '~/generated/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import {
FIELD_METADATA_ID,
FIELD_RELATION_METADATA_ID,
@@ -58,6 +57,31 @@ const fieldRelationMetadataItem: FieldMetadataItem = {
};
const mocks = [
{
request: {
query: queries.findManyViewsQuery,
variables: {
filter: {
objectMetadataId: { eq: '25611fce-6637-4089-b0ca-91afeec95784' },
},
},
},
result: jest.fn(() => ({
data: {
views: {
__typename: 'ViewConnection',
totalCount: 0,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
startCursor: '',
endCursor: '',
},
edges: [],
},
},
})),
},
{
request: {
query: queries.deleteMetadataField,
@@ -115,13 +139,9 @@ const mocks = [
},
];
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider mocks={mocks} addTypename={false}>
{children}
</MockedProvider>
</RecoilRoot>
);
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: mocks,
});
describe('useFieldMetadataItem', () => {
it('should activateMetadataField', async () => {
@@ -130,7 +150,10 @@ describe('useFieldMetadataItem', () => {
});
await act(async () => {
const res = await result.current.activateMetadataField(fieldMetadataItem);
const res = await result.current.activateMetadataField(
fieldMetadataItem.id,
objectMetadataId,
);
expect(res.data).toEqual({
updateOneField: responseData.default,
@@ -162,8 +185,10 @@ describe('useFieldMetadataItem', () => {
});
await act(async () => {
const res =
await result.current.deactivateMetadataField(fieldMetadataItem);
const res = await result.current.deactivateMetadataField(
fieldMetadataItem.id,
objectMetadataId,
);
expect(res.data).toEqual({
updateOneField: responseData.default,

View File

@@ -40,15 +40,23 @@ export const useFieldMetadataItem = () => {
});
};
const activateMetadataField = (metadataField: FieldMetadataItem) =>
const activateMetadataField = (
fieldMetadataId: string,
objectMetadataId: string,
) =>
updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: metadataField.id,
objectMetadataId: objectMetadataId,
fieldMetadataIdToUpdate: fieldMetadataId,
updatePayload: { isActive: true },
});
const deactivateMetadataField = (metadataField: FieldMetadataItem) =>
const deactivateMetadataField = (
fieldMetadataId: string,
objectMetadataId: string,
) =>
updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: metadataField.id,
objectMetadataId: objectMetadataId,
fieldMetadataIdToUpdate: fieldMetadataId,
updatePayload: { isActive: false },
});

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@apollo/client';
import { useApolloClient, useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import {
@@ -9,10 +9,27 @@ import {
import { UPDATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
import { useApolloMetadataClient } from './useApolloMetadataClient';
export const useUpdateOneFieldMetadataItem = () => {
const apolloMetadataClient = useApolloMetadataClient();
const apolloClient = useApolloClient();
const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular: CoreObjectNameSingular.View,
recordGqlFields: {
id: true,
viewGroups: {
id: true,
fieldMetadataId: true,
isVisible: true,
fieldValue: true,
position: true,
},
},
});
const [mutate] = useMutation<
UpdateOneFieldMetadataItemMutation,
@@ -22,9 +39,11 @@ export const useUpdateOneFieldMetadataItem = () => {
});
const updateOneFieldMetadataItem = async ({
objectMetadataId,
fieldMetadataIdToUpdate,
updatePayload,
}: {
objectMetadataId: string;
fieldMetadataIdToUpdate: UpdateOneFieldMetadataItemMutationVariables['idToUpdate'];
updatePayload: Pick<
UpdateOneFieldMetadataItemMutationVariables['updatePayload'],
@@ -37,7 +56,7 @@ export const useUpdateOneFieldMetadataItem = () => {
| 'options'
>;
}) => {
return await mutate({
const result = await mutate({
variables: {
idToUpdate: fieldMetadataIdToUpdate,
updatePayload: {
@@ -48,6 +67,20 @@ export const useUpdateOneFieldMetadataItem = () => {
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''],
});
await apolloClient.query({
query: findManyRecordsQuery,
variables: {
filter: {
objectMetadataId: {
eq: objectMetadataId,
},
},
},
fetchPolicy: 'network-only',
});
return result;
};
return {

View File

@@ -1,8 +1,8 @@
import { useRecoilCallback } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useSetRecordBoardColumns = (recordBoardId?: string) => {
const { scopeId, columnIdsState, columnsFamilySelector } =
@@ -19,11 +19,9 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => {
.filter(({ isVisible }) => isVisible)
.map(({ id }) => id);
if (isDeeplyEqual(currentColumnsIds, columnIds)) {
return;
}
if (!isDeeplyEqual(currentColumnsIds, columnIds)) {
set(columnIdsState, columnIds);
}
columns.forEach((column) => {
const currentColumn = snapshot

View File

@@ -127,7 +127,10 @@ export const SettingsObjectFieldItemTableRow = ({
const handleDisableField = async (
activeFieldMetadatItem: FieldMetadataItem,
) => {
await deactivateMetadataField(activeFieldMetadatItem);
await deactivateMetadataField(
activeFieldMetadatItem.id,
objectMetadataItem.id,
);
const deletedViewIds = allViews
.map((view) => {
@@ -272,7 +275,9 @@ export const SettingsObjectFieldItemTableRow = ({
isCustomField={fieldMetadataItem.isCustom === true}
scopeKey={fieldMetadataItem.id}
onEdit={() => navigate(linkToNavigate)}
onActivate={() => activateMetadataField(fieldMetadataItem)}
onActivate={() =>
activateMetadataField(fieldMetadataItem.id, objectMetadataItem.id)
}
onDelete={() => deleteMetadataField(fieldMetadataItem)}
/>
) : (

View File

@@ -137,6 +137,7 @@ export const SettingsObjectFieldEdit = () => {
if (isDefined(relationFieldMetadataItem)) {
await updateOneFieldMetadataItem({
objectMetadataId: objectMetadataItem.id,
fieldMetadataIdToUpdate: relationFieldMetadataItem.id,
updatePayload: formValues.relation.field,
});
@@ -152,6 +153,7 @@ export const SettingsObjectFieldEdit = () => {
);
await updateOneFieldMetadataItem({
objectMetadataId: objectMetadataItem.id,
fieldMetadataIdToUpdate: fieldMetadataItem.id,
updatePayload: formattedInput,
});
@@ -168,12 +170,12 @@ export const SettingsObjectFieldEdit = () => {
};
const handleDeactivate = async () => {
await deactivateMetadataField(fieldMetadataItem);
await deactivateMetadataField(fieldMetadataItem.id, objectMetadataItem.id);
navigate(`/settings/objects/${objectSlug}`);
};
const handleActivate = async () => {
await activateMetadataField(fieldMetadataItem);
await activateMetadataField(fieldMetadataItem.id, objectMetadataItem.id);
navigate(`/settings/objects/${objectSlug}`);
};

View File

@@ -15,6 +15,7 @@ import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dto
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -48,6 +49,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
services: [
IsFieldMetadataDefaultValue,
FieldMetadataService,
FieldMetadataRelatedRecordsService,
FieldMetadataValidationService,
],
resolvers: [

View File

@@ -21,6 +21,7 @@ import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
import {
computeColumnName,
@@ -28,6 +29,7 @@ import {
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import {
@@ -83,6 +85,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
) {
super(fieldMetadataRepository);
}
@@ -418,6 +421,16 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
);
}
if (
updatedFieldMetadata.isActive &&
isSelectFieldMetadataType(updatedFieldMetadata.type)
) {
await this.fieldMetadataRelatedRecordsService.updateRelatedViewGroups(
existingFieldMetadata,
updatedFieldMetadata,
);
}
if (
fieldMetadataInput.name ||
updatableFieldInput.options ||

View File

@@ -0,0 +1,151 @@
import { Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
type Differences<T> = {
created: T[];
updated: { old: T; new: T }[];
deleted: T[];
};
@Injectable()
export class FieldMetadataRelatedRecordsService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
public async updateRelatedViewGroups(
oldFieldMetadata: FieldMetadataEntity,
newFieldMetadata: FieldMetadataEntity,
) {
if (
!isSelectFieldMetadataType(newFieldMetadata.type) ||
!isSelectFieldMetadataType(oldFieldMetadata.type)
) {
return;
}
const views = await this.getFieldMetadataViews(newFieldMetadata);
const { created, updated, deleted } = this.getOptionsDifferences(
oldFieldMetadata.options,
newFieldMetadata.options,
);
const viewGroupRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewGroupWorkspaceEntity>(
newFieldMetadata.workspaceId,
'viewGroup',
);
for (const view of views) {
const maxPosition = view.viewGroups.reduce(
(max, viewGroup) => Math.max(max, viewGroup.position),
0,
);
const viewGroupsToCreate = created.map((option, index) =>
viewGroupRepository.create({
fieldMetadataId: newFieldMetadata.id,
fieldValue: option.value,
position: maxPosition + index,
isVisible: true,
viewId: view.id,
}),
);
await viewGroupRepository.insert(viewGroupsToCreate);
for (const { old: oldOption, new: newOption } of updated) {
const viewGroup = view.viewGroups.find(
(viewGroup) => viewGroup.fieldValue === oldOption.value,
);
if (!viewGroup) {
throw new Error(`View group not found for option ${oldOption.value}`);
}
await viewGroupRepository.update(
{
id: viewGroup.id,
},
{
fieldValue: newOption.value,
},
);
}
const valuesToDelete = deleted.map((option) => option.value);
await viewGroupRepository.delete({
fieldMetadataId: newFieldMetadata.id,
fieldValue: In(valuesToDelete),
});
}
}
private getOptionsDifferences(
oldOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[],
newOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[],
) {
const differences: Differences<
FieldMetadataDefaultOption | FieldMetadataComplexOption
> = {
created: [],
updated: [],
deleted: [],
};
const oldOptionsMap = new Map(
oldOptions.map((option) => [option.id, option]),
);
const newOptionsMap = new Map(
newOptions.map((option) => [option.id, option]),
);
for (const newOption of newOptions) {
const oldOption = oldOptionsMap.get(newOption.id);
if (!oldOption) {
differences.created.push(newOption);
} else if (oldOption.value !== newOption.value) {
differences.updated.push({ old: oldOption, new: newOption });
}
}
for (const oldOption of oldOptions) {
if (!newOptionsMap.has(oldOption.id)) {
differences.deleted.push(oldOption);
}
}
return differences;
}
private async getFieldMetadataViews(
fieldMetadata: FieldMetadataEntity,
): Promise<ViewWorkspaceEntity[]> {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
fieldMetadata.workspaceId,
'view',
);
return await viewRepository.find({
where: {
kanbanFieldMetadataId: fieldMetadata.id,
},
relations: ['viewGroups'],
});
}
}

View File

@@ -0,0 +1,7 @@
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const isSelectFieldMetadataType = (
type: FieldMetadataType,
): type is FieldMetadataType.SELECT => {
return type === FieldMetadataType.SELECT;
};