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:
@ -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",
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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"]",
|
||||
}
|
||||
`;
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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());
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[];
|
||||
}>;
|
||||
Reference in New Issue
Block a user