Fix view filter update and deletion propagation (#12082)

# Introduction

Diff description: ~500 tests and +500 additions

close https://github.com/twentyhq/core-team-issues/issues/731

## What has been done here
In a nutshell on a field metadata type ( `SELECT MULTI_SELECT` ) update,
we will be browsing all `ViewFilters` in a post hook searching for some
referencing related updated `fieldMetadata` select. In order to update
or delete the `viewFilter` depending on the associated mutations.

## How to test:
- Add FieldMetadata `SELECT | MULTI_SELECT` to an existing or a new
`objectMetadata`
- Create a filtered view on created `fieldMetadata` with any options you
would like
- Remove some options ( in the best of the world some that are selected
by the filter ) from the `fieldMetadata` settings page
- Go back to the filtered view, removed or updated options should have
been hydrated in the `displayValue` and the filtered data should make
sense

## All filtered options are deleted edge case
If an update implies that a viewFilter does not have any existing
related options anymore, then we remove the viewFilter

## Testing
```sh 
PASS  test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts (27 s)
  update-one-field-metadata-related-record
    SELECT
      ✓ should delete related view filter if all select field options got deleted (2799 ms)
      ✓ should update related multi selected options view filter (1244 ms)
      ✓ should update related solo selected option view filter (1235 ms)
      ✓ should handle partial deletion of selected options in view filter (1210 ms)
      ✓ should handle reordering of options while maintaining view filter values (1487 ms)
      ✓ should handle no changes update of options while maintaining existing view filter values (1174 ms)
      ✓ should handle adding new options while maintaining existing view filter (1174 ms)
      ✓ should update display value with options label if less than 3 options are selected (1249 ms)
      ✓ should throw error if view filter value is not a stringified JSON array (1300 ms)
    MULTI_SELECT
      ✓ should delete related view filter if all select field options got deleted (1127 ms)
      ✓ should update related multi selected options view filter (1215 ms)
      ✓ should update related solo selected option view filter (1404 ms)
      ✓ should handle partial deletion of selected options in view filter (1936 ms)
      ✓ should handle reordering of options while maintaining view filter values (1261 ms)
      ✓ should handle no changes update of options while maintaining existing view filter values (1831 ms)
      ✓ should handle adding new options while maintaining existing view filter (1610 ms)
      ✓ should update display value with options label if less than 3 options are selected (1889 ms)
      ✓ should throw error if view filter value is not a stringified JSON array (1365 ms)

Test Suites: 1 passed, 1 total
Tests:       18 passed, 18 total
Snapshots:   18 passed, 18 total
Time:        27.039 s
```
## Out of scope
- We should handle ViewFilter validation when extracting its definition
from the metadata
https://github.com/twentyhq/core-team-issues/issues/1009

## Concerns
- Are we able through the api to update an RATING fieldMetadata ? ( if
yes than that's an issue and we should handle RATING the same way than
for SELECT and MULTI_SELECT )
- It's not possible to group a view from a MULTI_SELECT field

The above points create a double nor a triple "lecture" to the post hook
effect:
- ViewGroup -> only SELECT
- VIewFilter -> only SELECT || MULTI_SELECT
- Rating nothing
I think we should determine the scope of all of that

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Paul Rastoin
2025-05-28 12:22:28 +02:00
committed by GitHub
parent e63cd39a5b
commit 97d4ec96af
31 changed files with 1250 additions and 93 deletions

View File

@ -64,6 +64,7 @@
"zod-to-json-schema": "^3.23.1"
},
"devDependencies": {
"@faker-js/faker": "^9.8.0",
"@lingui/cli": "^5.1.2",
"@nestjs/cli": "10.3.0",
"@nx/js": "18.3.3",

View File

@ -42,7 +42,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 { isSelectOrMultiSelectFieldMetadata } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.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 {
@ -255,12 +255,18 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
if (
updatedFieldMetadata.isActive &&
isSelectFieldMetadataType(updatedFieldMetadata.type)
isSelectOrMultiSelectFieldMetadata(updatedFieldMetadata) &&
isSelectOrMultiSelectFieldMetadata(existingFieldMetadata)
) {
await this.fieldMetadataRelatedRecordsService.updateRelatedViewGroups(
existingFieldMetadata,
updatedFieldMetadata,
);
await this.fieldMetadataRelatedRecordsService.updateRelatedViewFilters(
existingFieldMetadata,
updatedFieldMetadata,
);
}
if (

View File

@ -0,0 +1,347 @@
import { EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataDefaultOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
type GetOptionsDifferencesTestContext = EachTestingContext<{
oldOptions: FieldMetadataDefaultOption[];
newOptions: FieldMetadataDefaultOption[];
compareLabel?: boolean;
expected: {
created: FieldMetadataDefaultOption[];
updated: {
old: FieldMetadataDefaultOption;
new: FieldMetadataDefaultOption;
}[];
deleted: FieldMetadataDefaultOption[];
};
}>;
describe('FieldMetadataRelatedRecordsService', () => {
describe('getOptionsDifferences', () => {
let service: FieldMetadataRelatedRecordsService;
let twentyORMGlobalManager: TwentyORMGlobalManager;
beforeEach(() => {
twentyORMGlobalManager = {} as TwentyORMGlobalManager;
service = new FieldMetadataRelatedRecordsService(twentyORMGlobalManager);
});
const testCases: GetOptionsDifferencesTestContext[] = [
{
title: 'should identify created options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
],
newOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
expected: {
created: [
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
updated: [],
deleted: [],
},
},
},
{
title: 'should identify updated options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
],
newOptions: [
{
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
],
expected: {
created: [],
updated: [
{
old: {
id: '1',
label: 'Option 1',
value: 'value1',
position: 0,
},
new: {
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
},
],
deleted: [],
},
},
},
{
title: 'should identify deleted options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
newOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
],
expected: {
created: [],
updated: [],
deleted: [
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
},
},
},
{
title: 'should identify all types of changes',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
{ id: '3', label: 'Option 3', value: 'value3', position: 2 },
],
newOptions: [
{
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
{ id: '3', label: 'Option 3', value: 'value3', position: 1 },
{ id: '4', label: 'Option 4', value: 'value4', position: 2 },
],
expected: {
created: [
{ id: '4', label: 'Option 4', value: 'value4', position: 2 },
],
updated: [
{
old: {
id: '1',
label: 'Option 1',
value: 'value1',
position: 0,
},
new: {
id: '1',
label: 'Option 1',
value: 'updated-value1',
position: 0,
},
},
],
deleted: [
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
},
},
},
{
title: 'should handle empty arrays',
context: {
oldOptions: [],
newOptions: [],
expected: {
created: [],
updated: [],
deleted: [],
},
},
},
{
title: 'should handle all new options',
context: {
oldOptions: [],
newOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
expected: {
created: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
updated: [],
deleted: [],
},
},
},
{
title: 'should handle all deleted options',
context: {
oldOptions: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
newOptions: [],
expected: {
created: [],
updated: [],
deleted: [
{ id: '1', label: 'Option 1', value: 'value1', position: 0 },
{ id: '2', label: 'Option 2', value: 'value2', position: 1 },
],
},
},
},
{
title:
'should not consider changes to label as updates when value remains the same',
context: {
oldOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0',
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1',
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2',
value: 'option2',
position: 3,
},
],
newOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0_UPDATED', // Label changed but value remains the same
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1', // No change
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2_UPDATED', // Label changed but value remains the same
value: 'option2',
position: 3,
},
],
expected: {
created: [],
updated: [], // No updates because only labels changed, not values
deleted: [],
},
},
},
{
title:
'should consider changes to label as updates when value remains the same if compareLabel is true',
context: {
oldOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0',
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1',
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2',
value: 'option2',
position: 3,
},
],
newOptions: [
{
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0_UPDATED', // Label changed but value remains the same
value: 'option0',
position: 1,
},
{
id: '28d80b3c-79bd-4a1b-a868-9616534de0fa',
label: 'Option 1', // No change
value: 'option1',
position: 2,
},
{
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2_UPDATED', // Label changed but value remains the same
value: 'option2',
position: 3,
},
],
expected: {
created: [],
updated: [
{
new: {
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0_UPDATED',
position: 1,
value: 'option0',
},
old: {
id: 'f86eaffd-b773-4c9a-957b-86dca4a62731',
label: 'Option 0',
position: 1,
value: 'option0',
},
},
{
new: {
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2_UPDATED',
position: 3,
value: 'option2',
},
old: {
id: '25a05cd8-256f-4652-9e4a-6d9ca0b96f4d',
label: 'Option 2',
position: 3,
value: 'option2',
},
},
],
deleted: [],
},
compareLabel: true,
},
},
];
test.each(testCases)(
'$title',
({ context: { oldOptions, newOptions, expected, compareLabel } }) => {
const result = service.getOptionsDifferences(
oldOptions,
newOptions,
compareLabel,
);
expect(result.created).toEqual(expected.created);
expect(result.updated).toEqual(expected.updated);
expect(result.deleted).toEqual(expected.deleted);
},
);
});
});

View File

@ -1,15 +1,22 @@
import { Injectable } from '@nestjs/common';
import { MAX_OPTIONS_TO_DISPLAY } from 'twenty-shared/constants';
import { isDefined, parseJson } from 'twenty-shared/utils';
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 {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
import { SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@ -19,6 +26,10 @@ type Differences<T> = {
deleted: T[];
};
type GetOptionsDifferences = Differences<
FieldMetadataDefaultOption | FieldMetadataComplexOption
>;
@Injectable()
export class FieldMetadataRelatedRecordsService {
constructor(
@ -26,17 +37,20 @@ export class FieldMetadataRelatedRecordsService {
) {}
public async updateRelatedViewGroups(
oldFieldMetadata: FieldMetadataEntity,
newFieldMetadata: FieldMetadataEntity,
oldFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
newFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
): Promise<void> {
// TODO legacy should support multi-select and rating ?
if (
!isSelectFieldMetadataType(newFieldMetadata.type) ||
!isSelectFieldMetadataType(oldFieldMetadata.type)
) {
return;
}
const views = await this.getFieldMetadataViews(newFieldMetadata);
const views = await this.getFieldMetadataViewWithRelation(
newFieldMetadata,
'viewGroups',
);
const { created, updated, deleted } = this.getOptionsDifferences(
oldFieldMetadata.options,
@ -100,8 +114,113 @@ export class FieldMetadataRelatedRecordsService {
}
}
private computeViewFilterDisplayValue(
newViewFilterOptions: FieldMetadataDefaultOption[],
): string {
if (newViewFilterOptions.length > MAX_OPTIONS_TO_DISPLAY) {
return `${newViewFilterOptions.length} options`;
}
return newViewFilterOptions.map((option) => option.label).join(', ');
}
public async updateRelatedViewFilters(
oldFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
newFieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
): Promise<void> {
const views = await this.getFieldMetadataViewWithRelation(
newFieldMetadata,
'viewFilters',
);
const alsoCompareLabel = true;
const {
updated: updatedFieldMetadataOptions,
deleted: deletedFieldMetadataOptions,
} = this.getOptionsDifferences(
oldFieldMetadata.options,
newFieldMetadata.options,
alsoCompareLabel,
);
if (
updatedFieldMetadataOptions.length === 0 &&
deletedFieldMetadataOptions.length === 0
) {
return;
}
const viewFilterRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewFilterWorkspaceEntity>(
newFieldMetadata.workspaceId,
'viewFilter',
);
for (const filter of views) {
if (filter.viewFilters.length === 0) {
continue;
}
for (const viewFilter of filter.viewFilters) {
const viewFilterValue = parseJson<string[]>(viewFilter.value);
// Note below assertion could be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion
if (!isDefined(viewFilterValue) || !Array.isArray(viewFilterValue)) {
throw new FieldMetadataException(
`Unexpected invalid view filter value for filter ${viewFilter.id}`,
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const viewFilterOptions = viewFilterValue
.map((value) =>
oldFieldMetadata.options.find((option) => option.value === value),
)
.filter(isDefined);
const afterDeleteViewFilterOptions = viewFilterOptions.filter(
(viewFilterOption) =>
!deletedFieldMetadataOptions.some(
(option) => option.value === viewFilterOption.value,
),
);
if (afterDeleteViewFilterOptions.length === 0) {
await viewFilterRepository.delete({ id: viewFilter.id });
continue;
}
const afterUpdateAndDeleteViewFilterOptions =
afterDeleteViewFilterOptions.map((viewFilterOption) => {
const updatedOption = updatedFieldMetadataOptions.find(
({ old }) => viewFilterOption.value === old.value,
);
return isDefined(updatedOption)
? updatedOption.new
: viewFilterOption;
});
const displayValue = this.computeViewFilterDisplayValue(
afterUpdateAndDeleteViewFilterOptions,
);
const value = JSON.stringify(
afterUpdateAndDeleteViewFilterOptions.map((option) => option.value),
);
await viewFilterRepository.update(
{ id: viewFilter.id },
{
value,
displayValue,
},
);
}
}
}
async syncNoValueViewGroup(
fieldMetadata: FieldMetadataEntity,
fieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
view: ViewWorkspaceEntity,
viewGroupRepository: WorkspaceRepository<ViewGroupWorkspaceEntity>,
): Promise<void> {
@ -125,10 +244,11 @@ export class FieldMetadataRelatedRecordsService {
}
}
private getOptionsDifferences(
public getOptionsDifferences(
oldOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[],
newOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[],
): Differences<FieldMetadataDefaultOption | FieldMetadataComplexOption> {
compareLabel = false,
): GetOptionsDifferences {
const differences: Differences<
FieldMetadataDefaultOption | FieldMetadataComplexOption
> = {
@ -138,18 +258,26 @@ export class FieldMetadataRelatedRecordsService {
};
const oldOptionsMap = new Map(oldOptions.map((opt) => [opt.id, opt]));
const newOptionsMap = new Map(newOptions.map((opt) => [opt.id, opt]));
for (const newOption of newOptions) {
const oldOption = oldOptionsMap.get(newOption.id);
if (!oldOption) {
if (!isDefined(oldOption)) {
differences.created.push(newOption);
} else if (oldOption.value !== newOption.value) {
continue;
}
if (
oldOption.value !== newOption.value ||
(compareLabel && oldOption.label !== newOption.label)
) {
differences.updated.push({ old: oldOption, new: newOption });
continue;
}
}
const newOptionsMap = new Map(newOptions.map((opt) => [opt.id, opt]));
for (const oldOption of oldOptions) {
if (!newOptionsMap.has(oldOption.id)) {
differences.deleted.push(oldOption);
@ -159,8 +287,9 @@ export class FieldMetadataRelatedRecordsService {
return differences;
}
private async getFieldMetadataViews(
fieldMetadata: FieldMetadataEntity,
private async getFieldMetadataViewWithRelation(
fieldMetadata: SelectOrMultiSelectFieldMetadataEntity,
relation: keyof Pick<ViewWorkspaceEntity, 'viewGroups' | 'viewFilters'>,
): Promise<ViewWorkspaceEntity[]> {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
@ -170,11 +299,11 @@ export class FieldMetadataRelatedRecordsService {
return viewRepository.find({
where: {
viewGroups: {
[relation]: {
fieldMetadataId: fieldMetadata.id,
},
},
relations: ['viewGroups'],
relations: [relation],
});
}

View File

@ -0,0 +1,18 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export type SelectOrMultiSelectFieldMetadataEntity = FieldMetadataEntity<
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
>;
export const isSelectOrMultiSelectFieldMetadata = (
fieldMetadata: unknown,
): fieldMetadata is SelectOrMultiSelectFieldMetadataEntity => {
if (!(fieldMetadata instanceof FieldMetadataEntity)) {
return false;
}
return [FieldMetadataType.SELECT, FieldMetadataType.MULTI_SELECT].includes(
fieldMetadata.type,
);
};

View File

@ -13,13 +13,13 @@ export const createOneOperationFactory = ({
data = {},
}: CreateOneOperationFactoryParams) => ({
query: gql`
mutation Create${capitalize(objectMetadataSingularName)}($data: ${capitalize(objectMetadataSingularName)}CreateInput) {
create${capitalize(objectMetadataSingularName)}(data: $data) {
mutation CreateOne${capitalize(objectMetadataSingularName)}($input: ${capitalize(objectMetadataSingularName)}CreateInput!) {
create${capitalize(objectMetadataSingularName)}(data: $input) {
${gqlFields}
}
}
`,
variables: {
data,
input: data,
},
});

View File

@ -0,0 +1,43 @@
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { capitalize } from 'twenty-shared/utils';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
type CreateOneOperationArgs<T> = PerformMetadataQueryParams<T> & {
objectMetadataSingularName: string;
};
export const createOneOperation = async <T = object>({
input,
gqlFields = 'id',
objectMetadataSingularName,
expectToFail = false,
}: CreateOneOperationArgs<T>): CommonResponseBody<{
createOneResponse: ObjectRecord;
}> => {
const graphqlOperation = createOneOperationFactory({
data: input as object, // TODO default generic does not work
objectMetadataSingularName,
gqlFields,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
if (expectToFail) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Create one operation should have failed but did not',
});
}
return {
data: {
createOneResponse:
response.body.data[`create${capitalize(objectMetadataSingularName)}`],
},
errors: response.body.errors,
};
};

View File

@ -4,7 +4,7 @@ import { capitalize } from 'twenty-shared/utils';
type FindOneOperationFactoryParams = {
objectMetadataSingularName: string;
gqlFields: string;
filter?: object;
filter?: unknown;
};
export const findOneOperationFactory = ({
@ -13,7 +13,7 @@ export const findOneOperationFactory = ({
filter = {},
}: FindOneOperationFactoryParams) => ({
query: gql`
query ${capitalize(objectMetadataSingularName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput) {
query FindOne${capitalize(objectMetadataSingularName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput!) {
${objectMetadataSingularName}(filter: $filter) {
${gqlFields}
}

View File

@ -0,0 +1,40 @@
import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
type FindOneOperationArgs = Parameters<typeof findOneOperationFactory>[0] & {
expectToFail?: boolean;
};
export const findOneOperation = async ({
gqlFields = 'id',
objectMetadataSingularName,
expectToFail = false,
filter,
}: FindOneOperationArgs): CommonResponseBody<{
findResponse: ObjectRecord;
}> => {
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName,
gqlFields,
filter,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
if (expectToFail) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Find one operation should have failed but did not',
});
}
return {
data: {
findResponse: response.body.data[objectMetadataSingularName],
},
errors: response.body.errors,
};
};

View File

@ -0,0 +1,175 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`update-one-field-metadata-related-record MULTI_SELECT should delete related view filter if all select field options got deleted 1`] = `
[
{
"extensions": {
"code": "NOT_FOUND",
},
"message": "Record not found",
},
]
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle adding new options while maintaining existing view filter 1`] = `
{
"displayValue": "2 options",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle no changes update of options while maintaining existing view filter values 1`] = `
{
"displayValue": "10 options",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1","OPTION_2","OPTION_3","OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle partial deletion of selected options in view filter 1`] = `
{
"displayValue": "6 options",
"id": Any<String>,
"value": "["OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should handle reordering of options while maintaining view filter values 1`] = `
{
"displayValue": "2 options",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
},
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
},
]
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should update display value with options label if less than 3 options are selected 1`] = `
{
"displayValue": "Option 8, Option 9",
"id": Any<String>,
"value": "["OPTION_8","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should update related multi selected options view filter 1`] = `
{
"displayValue": "10 options",
"id": Any<String>,
"value": "["OPTION_0_UPDATED","OPTION_1","OPTION_2_UPDATED","OPTION_3","OPTION_4_UPDATED","OPTION_5","OPTION_6_UPDATED","OPTION_7","OPTION_8_UPDATED","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should update related solo selected option view filter 1`] = `
{
"displayValue": "Option 5 updated",
"id": Any<String>,
"value": "["OPTION_5_UPDATED"]",
}
`;
exports[`update-one-field-metadata-related-record MULTI_SELECT should update the display value on an option label change only 1`] = `
{
"displayValue": "Option 0 updated, Option 1 updated, Option 2 updated",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1","OPTION_2"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should delete related view filter if all select field options got deleted 1`] = `
[
{
"extensions": {
"code": "NOT_FOUND",
},
"message": "Record not found",
},
]
`;
exports[`update-one-field-metadata-related-record SELECT should handle adding new options while maintaining existing view filter 1`] = `
{
"displayValue": "2 options",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle no changes update of options while maintaining existing view filter values 1`] = `
{
"displayValue": "10 options",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1","OPTION_2","OPTION_3","OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle partial deletion of selected options in view filter 1`] = `
{
"displayValue": "6 options",
"id": Any<String>,
"value": "["OPTION_4","OPTION_5","OPTION_6","OPTION_7","OPTION_8","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should handle reordering of options while maintaining view filter values 1`] = `
{
"displayValue": "2 options",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should throw error if view filter value is not a stringified JSON array 1`] = `
[
{
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"exceptionEventId": "mocked-exception-id",
},
"message": "Unexpected invalid view filter value for filter 20202020-e3b5-4fa7-85aa-9b1950fc7bf5",
},
]
`;
exports[`update-one-field-metadata-related-record SELECT should update display value with options label if less than 3 options are selected 1`] = `
{
"displayValue": "Option 8, Option 9",
"id": Any<String>,
"value": "["OPTION_8","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should update related multi selected options view filter 1`] = `
{
"displayValue": "10 options",
"id": Any<String>,
"value": "["OPTION_0_UPDATED","OPTION_1","OPTION_2_UPDATED","OPTION_3","OPTION_4_UPDATED","OPTION_5","OPTION_6_UPDATED","OPTION_7","OPTION_8_UPDATED","OPTION_9"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should update related solo selected option view filter 1`] = `
{
"displayValue": "Option 5 updated",
"id": Any<String>,
"value": "["OPTION_5_UPDATED"]",
}
`;
exports[`update-one-field-metadata-related-record SELECT should update the display value on an option label change only 1`] = `
{
"displayValue": "Option 0 updated, Option 1 updated, Option 2 updated",
"id": Any<String>,
"value": "["OPTION_0","OPTION_1","OPTION_2"]",
}
`;

View File

@ -7,12 +7,15 @@ import {
LISTING_NAME_PLURAL,
LISTING_NAME_SINGULAR,
} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
const { failingTestCases, successfulTestCases } =
UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES;
@ -62,8 +65,9 @@ describe('Field metadata select creation tests group', () => {
expect(data).not.toBeNull();
expect(data.createOneField).toBeDefined();
const createdOptions: FieldMetadataComplexOption[] =
data.createOneField.options;
const createdOptions:
| FieldMetadataDefaultOption[]
| FieldMetadataComplexOption[] = data.createOneField.options;
const optionsToCompare = expectedOptions ?? input.options;

View File

@ -0,0 +1,381 @@
import { faker } from '@faker-js/faker';
import { isDefined } from 'class-validator';
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { findOneOperation } from 'test/integration/graphql/utils/find-one-operation.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { parseJson } from 'twenty-shared/utils';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption;
const generateOption = (index: number): Option => ({
label: `Option ${index}`,
value: `OPTION_${index}`,
color: 'green',
position: index,
});
const generateOptions = (length: number) =>
Array.from({ length }, (_value, index) => generateOption(index));
const updateOption = ({ value, label, ...option }: Option) => ({
...option,
value: `${value}_UPDATED`,
label: `${label} updated`,
});
const ALL_OPTIONS = generateOptions(10);
const isEven = (_value: unknown, index: number) => index % 2 === 0;
type ViewFilterUpdate = {
displayValue: string;
value: string[];
};
type FieldMetadataOptionsAndType = {
options: Option[];
type: EnumFieldMetadataType;
};
type TestCase = EachTestingContext<{
fieldMetadata?: FieldMetadataOptionsAndType;
createViewFilter?: ViewFilterUpdate;
updateOptions: (
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
) => FieldMetadataDefaultOption[] | FieldMetadataComplexOption[];
expected?: null;
}>;
const testFieldMetadataType: EnumFieldMetadataType[] = [
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
];
describe('update-one-field-metadata-related-record', () => {
let idToDelete: string;
const createObjectSelectFieldAndView = async ({
options,
type: fieldMetadataType,
}: FieldMetadataOptionsAndType) => {
const singular = faker.lorem.words();
const plural = singular + faker.lorem.word();
const {
data: { createOneObject },
} = await createOneObjectMetadata({
input: getMockCreateObjectInput({
labelSingular: singular,
labelPlural: plural,
nameSingular: singular.split(' ').join(''),
namePlural: plural.split(' ').join(''),
isLabelSyncedWithName: false,
}),
});
idToDelete = createOneObject.id;
const {
data: { createOneField },
} = await createOneFieldMetadata({
input: {
objectMetadataId: createOneObject.id,
type: fieldMetadataType,
name: 'testName',
label: 'Test name',
isLabelSyncedWithName: true,
options,
},
gqlFields: `
id
options
`,
});
const {
data: { createOneResponse: createOneView },
} = await createOneOperation<{
id: string;
objectMetadataId: string;
type: string;
}>({
objectMetadataSingularName: 'view',
input: {
id: faker.string.uuid(),
objectMetadataId: createOneObject.id,
type: 'table',
},
});
return { createOneObject, createOneField, createOneView };
};
afterEach(async () => {
if (isDefined(idToDelete)) {
await deleteOneObjectMetadata({
input: { idToDelete: idToDelete },
});
}
});
describe.each(testFieldMetadataType)('%s', (fieldType) => {
const testCases: TestCase[] = [
{
title:
'should delete related view filter if all select field options got deleted',
context: {
updateOptions: () => generateOptions(3),
expected: null,
},
},
{
title: 'should update related multi selected options view filter',
context: {
updateOptions: (options) =>
options.map((option, index) =>
isEven(option, index) ? updateOption(option) : option,
),
},
},
{
title: 'should update related solo selected option view filter',
context: {
createViewFilter: {
displayValue: ALL_OPTIONS[5].label,
value: [ALL_OPTIONS[5].value],
},
updateOptions: (options) => [updateOption(options[5])],
},
},
{
title:
'should handle partial deletion of selected options in view filter',
context: {
updateOptions: (options) => options.slice(4),
},
},
{
title:
'should handle reordering of options while maintaining view filter values',
context: {
createViewFilter: {
displayValue: '2 options',
value: ALL_OPTIONS.slice(0, 2).map((option) => option.value),
},
updateOptions: (options) => [...options].reverse(),
},
},
{
title:
'should handle no changes update of options while maintaining existing view filter values',
context: {
updateOptions: (options) => options,
},
},
{
title:
'should handle adding new options while maintaining existing view filter',
context: {
fieldMetadata: {
options: ALL_OPTIONS.slice(0, 5),
type: fieldType,
},
createViewFilter: {
displayValue: '2 options',
value: ALL_OPTIONS.slice(0, 2).map((option) => option.value),
},
updateOptions: (options) => [
...options,
...generateOptions(6).slice(5),
],
},
},
{
title:
'should update display value with options label if less than 3 options are selected',
context: {
updateOptions: (options) => options.slice(8),
},
},
{
title: 'should update the display value on an option label change only',
context: {
createViewFilter: {
displayValue: 'Option 3',
value: ALL_OPTIONS.slice(0, 3).map((option) => option.value),
},
updateOptions: (options) =>
options.map((option) => ({
...option,
label: `${option.label} updated`,
})),
},
},
];
test.each(testCases)(
'$title',
async ({
context: {
expected,
createViewFilter = {
displayValue: '10 options',
value: ALL_OPTIONS.map((option) => option.value),
},
fieldMetadata = { options: ALL_OPTIONS, type: fieldType },
updateOptions,
},
}) => {
const { createOneField, createOneView } =
await createObjectSelectFieldAndView(fieldMetadata);
const {
data: { createOneResponse: createOneViewFilter },
} = await createOneOperation<{
id: string;
viewId: string;
fieldMetadataId: string;
operand: string;
value: string;
displayValue: string;
}>({
objectMetadataSingularName: 'viewFilter',
input: {
id: faker.string.uuid(),
viewId: createOneView.id,
fieldMetadataId: createOneField.id,
operand: 'is',
value: JSON.stringify(createViewFilter.value),
displayValue: createViewFilter.displayValue,
},
});
const optionsWithIds = createOneField.options;
const updatedOptions = updateOptions(optionsWithIds);
await updateOneFieldMetadata({
input: {
idToUpdate: createOneField.id,
updatePayload: {
options: updatedOptions,
},
},
gqlFields: `
id
options
`,
});
const {
data: { findResponse },
errors,
} = await findOneOperation({
gqlFields: `
id
displayValue
value
`,
objectMetadataSingularName: 'viewFilter',
filter: {
id: { eq: createOneViewFilter.id },
},
});
if (expected !== undefined) {
expect(findResponse).toBe(expected);
expect(errors).toMatchSnapshot();
return;
}
const parsedViewFilterValues = parseJson<string[]>(findResponse.value);
expect(parsedViewFilterValues).not.toBeNull();
if (parsedViewFilterValues === null) {
throw new Error('Invariant parsedValue should not be null');
}
expect(updatedOptions.map((option) => option.value)).toEqual(
expect.arrayContaining(parsedViewFilterValues),
);
expect(findResponse).toMatchSnapshot({
id: expect.any(String),
});
},
);
// Note these test exists only because we do not validate the view filter value on creation/update
// Should be removed after https://github.com/twentyhq/core-team-issues/issues/1009 completion
const failingTestCases: EachTestingContext<{
createViewFilterValue: unknown;
}>[] = [
{
title:
'should throw error if view filter value is not a stringified JSON array',
context: {
createViewFilterValue: JSON.stringify(
'not an array stringified json',
),
},
},
];
test.each(failingTestCases)(
'$title',
async ({ context: { createViewFilterValue } }) => {
const { createOneField, createOneView } =
await createObjectSelectFieldAndView({
options: ALL_OPTIONS,
type: fieldType,
});
const viewFilterId = '20202020-e3b5-4fa7-85aa-9b1950fc7bf5';
await createOneOperation<{
id: string;
viewId: string;
fieldMetadataId: string;
operand: string;
value: string;
displayValue: string;
}>({
objectMetadataSingularName: 'viewFilter',
input: {
id: viewFilterId,
viewId: createOneView.id,
fieldMetadataId: createOneField.id,
operand: 'is',
value: createViewFilterValue as unknown as string,
displayValue: '10 options',
},
});
const optionsWithIds = createOneField.options;
const updatePayload = {
options: optionsWithIds.map((option) => updateOption(option)),
};
const { errors, data } = await updateOneFieldMetadata({
input: {
idToUpdate: createOneField.id,
updatePayload,
},
gqlFields: `
id
options
`,
});
expect(data).toBeNull();
expect(errors).toBeDefined();
expect(errors).toMatchSnapshot();
},
);
});
});

View File

@ -9,12 +9,15 @@ import {
LISTING_NAME_PLURAL,
LISTING_NAME_SINGULAR,
} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
const { failingTestCases, successfulTestCases } =
UPDATE_CREATE_ONE_FIELD_METADATA_SELECT_TEST_CASES;
@ -146,8 +149,9 @@ describe('Field metadata select update tests group', () => {
});
expect(data.updateOneField).toBeDefined();
const updatedOptions: FieldMetadataComplexOption[] =
data.updateOneField.options;
const updatedOptions:
| FieldMetadataComplexOption[]
| FieldMetadataDefaultOption[] = data.updateOneField.options;
expect(errors).toBeUndefined();
updatedOptions.forEach((option) => expect(option.id).toBeDefined());

View File

@ -3,14 +3,19 @@ import {
createOneFieldMetadataQueryFactory,
} from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const createOneFieldMetadata = async ({
input,
gqlFields,
expectToFail = false,
}: PerformMetadataQueryParams<CreateOneFieldFactoryInput>) => {
}: PerformMetadataQueryParams<CreateOneFieldFactoryInput>): CommonResponseBody<{
createOneField: FieldMetadataEntity;
}> => {
const graphqlOperation = createOneFieldMetadataQueryFactory({
input,
gqlFields,

View File

@ -3,14 +3,19 @@ import {
UpdateOneFieldFactoryInput,
updateOneFieldMetadataQueryFactory,
} from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata-query-factory.util';
import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const updateOneFieldMetadata = async ({
input,
gqlFields,
expectToFail = false,
}: PerformMetadataQueryParams<UpdateOneFieldFactoryInput>) => {
}: PerformMetadataQueryParams<UpdateOneFieldFactoryInput>): CommonResponseBody<{
updateOneField: FieldMetadataEntity;
}> => {
const graphqlOperation = updateOneFieldMetadataQueryFactory({
input,
gqlFields,

View File

@ -3,14 +3,19 @@ import {
createOneObjectMetadataQueryFactory,
} from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export const createOneObjectMetadata = async ({
input,
gqlFields,
expectToFail = false,
}: PerformMetadataQueryParams<CreateOneObjectFactoryInput>) => {
}: PerformMetadataQueryParams<CreateOneObjectFactoryInput>): CommonResponseBody<{
createOneObject: ObjectMetadataEntity; // not accurate
}> => {
const graphqlOperation = createOneObjectMetadataQueryFactory({
input,
gqlFields,

View File

@ -0,0 +1,6 @@
import { BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export type CommonResponseBody<T> = Promise<{
data: T;
errors: BaseGraphQLError[];
}>;