add UpsertViewWidget resolver (#20053)

This commit is contained in:
Abdul Rahman
2026-04-29 15:11:02 +05:30
committed by GitHub
parent b92617d46e
commit 19ee9444ed
51 changed files with 3274 additions and 1114 deletions
@@ -0,0 +1,40 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AtLeastOneOf } from 'src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator';
@InputType()
@AtLeastOneOf(['viewFieldId', 'fieldMetadataId'])
export class UpsertViewWidgetViewFieldInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, {
nullable: true,
description: 'The id of an existing view field to update.',
})
viewFieldId?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, {
nullable: true,
description:
'The field metadata id. Used to create a new view field when viewFieldId is not provided.',
})
fieldMetadataId?: string;
@IsBoolean()
@Field()
isVisible: boolean;
@IsNumber()
@Field()
position: number;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
size?: number;
}
@@ -0,0 +1,30 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsUUID } from 'class-validator';
import { ViewFilterGroupLogicalOperator } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpsertViewWidgetViewFilterGroupInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
parentViewFilterGroupId?: string;
@Field(() => ViewFilterGroupLogicalOperator, {
nullable: true,
defaultValue: ViewFilterGroupLogicalOperator.AND,
})
logicalOperator?: ViewFilterGroupLogicalOperator;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
positionInViewFilterGroup?: number;
}
@@ -0,0 +1,51 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsDefined,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { ViewFilterOperand } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
@InputType()
export class UpsertViewWidgetViewFilterInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@IsOptional()
@IsEnum(ViewFilterOperand)
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
operand?: ViewFilterOperand;
@IsDefined()
@Field(() => GraphQLJSON, { nullable: false })
value: ViewFilterValue;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
viewFilterGroupId?: string;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
positionInViewFilterGroup?: number;
@IsOptional()
@IsString()
@Field({ nullable: true })
subFieldName?: string;
}
@@ -0,0 +1,26 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
import { ViewSortDirection } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpsertViewWidgetViewSortInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@IsOptional()
@IsEnum(ViewSortDirection)
@Field(() => ViewSortDirection, {
nullable: true,
defaultValue: ViewSortDirection.ASC,
})
direction?: ViewSortDirection;
}
@@ -0,0 +1,61 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
import { UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
import { UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
import { UpsertViewWidgetViewSortInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-sort.input';
@InputType()
export class UpsertViewWidgetInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType, {
description: 'The id of the view widget (page layout widget).',
})
widgetId: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFieldInput)
@Field(() => [UpsertViewWidgetViewFieldInput], {
nullable: true,
description: 'The view fields to upsert.',
})
viewFields?: UpsertViewWidgetViewFieldInput[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFilterInput)
@Field(() => [UpsertViewWidgetViewFilterInput], {
nullable: true,
description: 'The view filters to upsert.',
})
viewFilters?: UpsertViewWidgetViewFilterInput[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFilterGroupInput)
@Field(() => [UpsertViewWidgetViewFilterGroupInput], {
nullable: true,
description: 'The view filter groups to upsert.',
})
viewFilterGroups?: UpsertViewWidgetViewFilterGroupInput[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewSortInput)
@Field(() => [UpsertViewWidgetViewSortInput], {
nullable: true,
description: 'The view sorts to upsert.',
})
viewSorts?: UpsertViewWidgetViewSortInput[];
}
@@ -21,6 +21,7 @@ export enum ViewExceptionCode {
INVALID_VIEW_DATA = 'INVALID_VIEW_DATA',
VIEW_CREATE_PERMISSION_DENIED = 'VIEW_CREATE_PERMISSION_DENIED',
VIEW_MODIFY_PERMISSION_DENIED = 'VIEW_MODIFY_PERMISSION_DENIED',
VIEW_WIDGET_NOT_FOUND = 'VIEW_WIDGET_NOT_FOUND',
}
export enum ViewExceptionMessageKey {
@@ -1,4 +1,4 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
Args,
Context,
@@ -8,10 +8,12 @@ import {
ResolveField,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ViewType, ViewVisibility } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -20,6 +22,7 @@ import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-worksp
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
@@ -34,7 +37,10 @@ import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-perm
import { ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import { CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input';
import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
import { UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@@ -44,6 +50,7 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
export class ViewResolver {
constructor(
private readonly viewService: ViewService,
private readonly viewWidgetUpsertService: ViewWidgetUpsertService,
private readonly i18nService: I18nService,
) {}
@@ -197,6 +204,19 @@ export class ViewResolver {
return isDefined(deletedView);
}
@Mutation(() => ViewDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
@UsePipes(ResolverValidationPipe)
async upsertViewWidget(
@Args('input') input: UpsertViewWidgetInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewEntity> {
return await this.viewWidgetUpsertService.upsertViewWidget({
input,
workspaceId,
});
}
@ResolveField(() => [ViewFieldDTO])
async viewFields(
@Parent() view: ViewDTO,
@@ -0,0 +1,859 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import {
ViewFilterGroupLogicalOperator,
ViewFilterOperand,
ViewSortDirection,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { type FlatViewFilterGroupMaps } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group-maps.type';
import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group.type';
import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type';
import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { type UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
import { type UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
import { type UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
import { type UpsertViewWidgetViewSortInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-sort.input';
import { type UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import {
ViewException,
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const EMPTY_FIELD_OPS = {
fieldsToCreate: [] as FlatViewField[],
fieldsToUpdate: [] as FlatViewField[],
};
const EMPTY_FILTER_GROUP_OPS = {
filterGroupsToCreate: [] as FlatViewFilterGroup[],
filterGroupsToUpdate: [] as FlatViewFilterGroup[],
filterGroupsToRemove: [] as FlatViewFilterGroup[],
};
const EMPTY_FILTER_OPS = {
filtersToCreate: [] as FlatViewFilter[],
filtersToUpdate: [] as FlatViewFilter[],
filtersToRemove: [] as FlatViewFilter[],
};
const EMPTY_SORT_OPS = {
sortsToCreate: [] as FlatViewSort[],
sortsToUpdate: [] as FlatViewSort[],
sortsToRemove: [] as FlatViewSort[],
};
@Injectable()
export class ViewWidgetUpsertService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
) {}
async upsertViewWidget({
input,
workspaceId,
}: {
input: UpsertViewWidgetInput;
workspaceId: string;
}): Promise<ViewEntity> {
const { widgetId } = input;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const {
flatPageLayoutWidgetMaps,
flatFieldMetadataMaps,
flatViewFieldMaps,
flatViewFilterMaps,
flatViewFilterGroupMaps,
flatViewSortMaps,
flatViewMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutWidgetMaps',
'flatFieldMetadataMaps',
'flatViewFieldMaps',
'flatViewFilterMaps',
'flatViewFilterGroupMaps',
'flatViewSortMaps',
'flatViewMaps',
],
},
);
const widget = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: widgetId,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
if (
!isDefined(widget) ||
!isFlatPageLayoutWidgetConfigurationOfType(
widget,
WidgetConfigurationType.RECORD_TABLE,
)
) {
throw new ViewException(
t`Record table widget not found`,
ViewExceptionCode.VIEW_WIDGET_NOT_FOUND,
);
}
const viewId = widget.configuration.viewId;
if (!isDefined(viewId)) {
throw new ViewException(
t`Record table widget has no associated view`,
ViewExceptionCode.VIEW_WIDGET_NOT_FOUND,
);
}
const flatView = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewId,
flatEntityMaps: flatViewMaps,
});
if (!isDefined(flatView)) {
throw new ViewException(
t`View not found for widget`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
const upsertContext = {
viewId,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
now: new Date().toISOString(),
};
if (
!isDefined(input.viewFields) &&
!isDefined(input.viewFilterGroups) &&
!isDefined(input.viewFilters) &&
!isDefined(input.viewSorts)
) {
const view = await this.viewRepository.findOne({
where: {
id: upsertContext.viewId,
workspaceId: upsertContext.workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(view)) {
throw new ViewException(
t`View not found`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return view;
}
const existingViewFields = Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((field) => field.isActive && field.viewId === viewId);
const existingViewFilters = Object.values(
flatViewFilterMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((filter) => filter.viewId === viewId);
const existingViewFilterGroups = Object.values(
flatViewFilterGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((group) => group.viewId === viewId);
const existingViewSorts = Object.values(
flatViewSortMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((sort) => sort.viewId === viewId);
const viewFieldOperations = isDefined(input.viewFields)
? this.computeViewFieldOperations({
inputFields: input.viewFields,
existingViewFields,
...upsertContext,
flatFieldMetadataMaps,
flatViewMaps,
})
: EMPTY_FIELD_OPS;
const viewFilterGroupOperations = isDefined(input.viewFilterGroups)
? this.computeViewFilterGroupOperations({
inputFilterGroups: input.viewFilterGroups,
existingViewFilterGroups,
...upsertContext,
flatViewMaps,
flatViewFilterGroupMaps,
})
: EMPTY_FILTER_GROUP_OPS;
const optimisticFilterGroupMaps =
viewFilterGroupOperations.filterGroupsToCreate.reduce(
(maps, group) =>
addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: group,
flatEntityMaps: maps,
}),
flatViewFilterGroupMaps,
);
const viewFilterOperations = isDefined(input.viewFilters)
? this.computeViewFilterOperations({
inputFilters: input.viewFilters,
existingViewFilters,
...upsertContext,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps: optimisticFilterGroupMaps,
})
: EMPTY_FILTER_OPS;
const viewSortOperations = isDefined(input.viewSorts)
? this.computeViewSortOperations({
inputSorts: input.viewSorts,
existingViewSorts,
...upsertContext,
flatFieldMetadataMaps,
flatViewMaps,
})
: EMPTY_SORT_OPS;
const {
toHardDelete: filterGroupsToDelete,
toDeactivate: filterGroupsToDeactivate,
} = splitEntitiesByRemovalStrategy({
entitiesToRemove: viewFilterGroupOperations.filterGroupsToRemove,
workspaceCustomApplicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
now: upsertContext.now,
});
const { toHardDelete: filtersToDelete, toDeactivate: filtersToDeactivate } =
splitEntitiesByRemovalStrategy({
entitiesToRemove: viewFilterOperations.filtersToRemove,
workspaceCustomApplicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
now: upsertContext.now,
});
const { toHardDelete: sortsToDelete, toDeactivate: sortsToDeactivate } =
splitEntitiesByRemovalStrategy({
entitiesToRemove: viewSortOperations.sortsToRemove,
workspaceCustomApplicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
now: upsertContext.now,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
viewField: {
flatEntityToCreate: viewFieldOperations.fieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: viewFieldOperations.fieldsToUpdate,
},
viewFilterGroup: {
flatEntityToCreate:
viewFilterGroupOperations.filterGroupsToCreate,
flatEntityToDelete: filterGroupsToDelete,
flatEntityToUpdate: [
...viewFilterGroupOperations.filterGroupsToUpdate,
...filterGroupsToDeactivate,
],
},
viewFilter: {
flatEntityToCreate: viewFilterOperations.filtersToCreate,
flatEntityToDelete: filtersToDelete,
flatEntityToUpdate: [
...viewFilterOperations.filtersToUpdate,
...filtersToDeactivate,
],
},
viewSort: {
flatEntityToCreate: viewSortOperations.sortsToCreate,
flatEntityToDelete: sortsToDelete,
flatEntityToUpdate: [
...viewSortOperations.sortsToUpdate,
...sortsToDeactivate,
],
},
},
workspaceId: upsertContext.workspaceId,
isSystemBuild: false,
applicationUniversalIdentifier:
upsertContext.applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while upserting view widget',
);
}
const view = await this.viewRepository.findOne({
where: {
id: upsertContext.viewId,
workspaceId: upsertContext.workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(view)) {
throw new ViewException(
t`View not found after upsert`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return view;
}
private computeViewFieldOperations({
inputFields,
existingViewFields,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatFieldMetadataMaps,
flatViewMaps,
}: {
inputFields: UpsertViewWidgetViewFieldInput[];
existingViewFields: FlatViewField[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
}): {
fieldsToCreate: FlatViewField[];
fieldsToUpdate: FlatViewField[];
} {
const fieldsToCreate: FlatViewField[] = [];
const fieldsToUpdate: FlatViewField[] = [];
for (const inputField of inputFields) {
const existingField = isDefined(inputField.viewFieldId)
? existingViewFields.find((f) => f.id === inputField.viewFieldId)
: isDefined(inputField.fieldMetadataId)
? existingViewFields.find(
(f) => f.fieldMetadataId === inputField.fieldMetadataId,
)
: undefined;
if (isDefined(existingField)) {
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
? existingField.overrides.isVisible
: existingField.isVisible;
const resolvedPosition = isDefined(existingField.overrides?.position)
? existingField.overrides.position
: existingField.position;
const resolvedSize = isDefined(existingField.overrides?.size)
? existingField.overrides.size
: existingField.size;
const hasChanged =
resolvedIsVisible !== inputField.isVisible ||
resolvedPosition !== inputField.position ||
(isDefined(inputField.size) && resolvedSize !== inputField.size);
if (!hasChanged) {
continue;
}
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingField.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingField,
updatedEditableProperties: {
isVisible: inputField.isVisible,
position: inputField.position,
...(isDefined(inputField.size) ? { size: inputField.size } : {}),
},
shouldOverride,
});
const updatedField: FlatViewField = {
...existingField,
...sanitizedFieldProps,
overrides,
updatedAt: now,
};
if (isDefined(overrides)) {
updatedField.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById: {},
});
} else {
updatedField.universalOverrides = null;
}
fieldsToUpdate.push(updatedField);
continue;
}
if (!isDefined(inputField.fieldMetadataId)) {
continue;
}
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: inputField.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(fieldMetadata)) {
continue;
}
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
fieldMetadataId: inputField.fieldMetadataId,
viewId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
},
});
fieldsToCreate.push({
id: v4(),
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputField.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
isVisible: inputField.isVisible,
size: inputField.size ?? DEFAULT_VIEW_FIELD_SIZE,
position: inputField.position,
aggregateOperation: null,
overrides: null,
universalOverrides: null,
isActive: true,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
}
return { fieldsToCreate, fieldsToUpdate };
}
private computeViewFilterGroupOperations({
inputFilterGroups,
existingViewFilterGroups,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatViewMaps,
flatViewFilterGroupMaps,
}: {
inputFilterGroups: UpsertViewWidgetViewFilterGroupInput[];
existingViewFilterGroups: FlatViewFilterGroup[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatViewMaps: FlatViewMaps;
flatViewFilterGroupMaps: FlatViewFilterGroupMaps;
}): {
filterGroupsToCreate: FlatViewFilterGroup[];
filterGroupsToUpdate: FlatViewFilterGroup[];
filterGroupsToRemove: FlatViewFilterGroup[];
} {
const filterGroupsToCreate: FlatViewFilterGroup[] = [];
const filterGroupsToUpdate: FlatViewFilterGroup[] = [];
const inputFilterGroupIds = new Set(
inputFilterGroups.map((g) => g.id).filter(isDefined),
);
for (const inputGroup of inputFilterGroups) {
const existingGroup = isDefined(inputGroup.id)
? existingViewFilterGroups.find((g) => g.id === inputGroup.id)
: undefined;
if (!isDefined(existingGroup)) {
const filterGroupId = inputGroup.id ?? v4();
const {
viewUniversalIdentifier,
parentViewFilterGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilterGroup',
foreignKeyValues: {
viewId,
parentViewFilterGroupId: inputGroup.parentViewFilterGroupId,
},
flatEntityMaps: { flatViewMaps, flatViewFilterGroupMaps },
});
filterGroupsToCreate.push({
id: filterGroupId,
workspaceId,
applicationId,
universalIdentifier: filterGroupId,
applicationUniversalIdentifier,
viewId,
viewUniversalIdentifier,
logicalOperator:
inputGroup.logicalOperator ?? ViewFilterGroupLogicalOperator.AND,
parentViewFilterGroupId: inputGroup.parentViewFilterGroupId ?? null,
parentViewFilterGroupUniversalIdentifier,
positionInViewFilterGroup:
inputGroup.positionInViewFilterGroup ?? null,
viewFilterIds: [],
viewFilterUniversalIdentifiers: [],
childViewFilterGroupIds: [],
childViewFilterGroupUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,
deletedAt: null,
});
} else {
const hasChanged =
existingGroup.logicalOperator !== inputGroup.logicalOperator ||
existingGroup.positionInViewFilterGroup !==
inputGroup.positionInViewFilterGroup ||
existingGroup.parentViewFilterGroupId !==
inputGroup.parentViewFilterGroupId;
if (hasChanged) {
const resolvedParentId =
inputGroup.parentViewFilterGroupId ??
existingGroup.parentViewFilterGroupId;
const { parentViewFilterGroupUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilterGroup',
foreignKeyValues: {
parentViewFilterGroupId: resolvedParentId,
},
flatEntityMaps: { flatViewFilterGroupMaps },
});
filterGroupsToUpdate.push({
...existingGroup,
logicalOperator:
inputGroup.logicalOperator ?? existingGroup.logicalOperator,
positionInViewFilterGroup:
inputGroup.positionInViewFilterGroup ??
existingGroup.positionInViewFilterGroup,
parentViewFilterGroupId: resolvedParentId,
parentViewFilterGroupUniversalIdentifier,
updatedAt: now,
});
}
}
}
const filterGroupsToRemove = existingViewFilterGroups.filter(
(g) => !inputFilterGroupIds.has(g.id),
);
return { filterGroupsToCreate, filterGroupsToUpdate, filterGroupsToRemove };
}
private computeViewFilterOperations({
inputFilters,
existingViewFilters,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps,
}: {
inputFilters: UpsertViewWidgetViewFilterInput[];
existingViewFilters: FlatViewFilter[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
flatViewFilterGroupMaps: FlatViewFilterGroupMaps;
}): {
filtersToCreate: FlatViewFilter[];
filtersToUpdate: FlatViewFilter[];
filtersToRemove: FlatViewFilter[];
} {
const filtersToCreate: FlatViewFilter[] = [];
const filtersToUpdate: FlatViewFilter[] = [];
const inputFilterIds = new Set(
inputFilters.map((f) => f.id).filter(isDefined),
);
for (const inputFilter of inputFilters) {
const existingFilter = isDefined(inputFilter.id)
? existingViewFilters.find((f) => f.id === inputFilter.id)
: undefined;
if (!isDefined(existingFilter)) {
const filterId = inputFilter.id ?? v4();
const {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
flatViewFilterGroupMaps,
},
});
filtersToCreate.push({
id: filterId,
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputFilter.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
operand: inputFilter.operand ?? ViewFilterOperand.CONTAINS,
value: inputFilter.value,
viewFilterGroupId: inputFilter.viewFilterGroupId ?? null,
viewFilterGroupUniversalIdentifier,
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ?? null,
subFieldName: inputFilter.subFieldName ?? null,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
} else {
const hasChanged =
existingFilter.fieldMetadataId !== inputFilter.fieldMetadataId ||
existingFilter.operand !== inputFilter.operand ||
JSON.stringify(existingFilter.value) !==
JSON.stringify(inputFilter.value) ||
existingFilter.viewFilterGroupId !== inputFilter.viewFilterGroupId ||
existingFilter.positionInViewFilterGroup !==
inputFilter.positionInViewFilterGroup ||
existingFilter.subFieldName !== inputFilter.subFieldName;
if (hasChanged) {
const {
fieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewFilterGroupMaps,
},
});
filtersToUpdate.push({
...existingFilter,
fieldMetadataId: inputFilter.fieldMetadataId,
fieldMetadataUniversalIdentifier,
operand: inputFilter.operand ?? existingFilter.operand,
value: inputFilter.value,
viewFilterGroupId:
inputFilter.viewFilterGroupId ?? existingFilter.viewFilterGroupId,
viewFilterGroupUniversalIdentifier,
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ??
existingFilter.positionInViewFilterGroup,
subFieldName:
inputFilter.subFieldName ?? existingFilter.subFieldName,
updatedAt: now,
});
}
}
}
const filtersToRemove = existingViewFilters.filter(
(f) => !inputFilterIds.has(f.id),
);
return { filtersToCreate, filtersToUpdate, filtersToRemove };
}
private computeViewSortOperations({
inputSorts,
existingViewSorts,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
now,
flatFieldMetadataMaps,
flatViewMaps,
}: {
inputSorts: UpsertViewWidgetViewSortInput[];
existingViewSorts: FlatViewSort[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
now: string;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
}): {
sortsToCreate: FlatViewSort[];
sortsToUpdate: FlatViewSort[];
sortsToRemove: FlatViewSort[];
} {
const sortsToCreate: FlatViewSort[] = [];
const sortsToUpdate: FlatViewSort[] = [];
const inputSortIds = new Set(inputSorts.map((s) => s.id).filter(isDefined));
for (const inputSort of inputSorts) {
const existingSort = isDefined(inputSort.id)
? existingViewSorts.find((s) => s.id === inputSort.id)
: undefined;
if (!isDefined(existingSort)) {
const sortId = inputSort.id ?? v4();
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewSort',
foreignKeyValues: {
fieldMetadataId: inputSort.fieldMetadataId,
viewId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
},
});
sortsToCreate.push({
id: sortId,
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputSort.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
direction: inputSort.direction ?? ViewSortDirection.ASC,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
} else {
const hasChanged =
existingSort.fieldMetadataId !== inputSort.fieldMetadataId ||
existingSort.direction !== inputSort.direction;
if (hasChanged) {
const { fieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewSort',
foreignKeyValues: {
fieldMetadataId: inputSort.fieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
},
});
sortsToUpdate.push({
...existingSort,
fieldMetadataId: inputSort.fieldMetadataId,
fieldMetadataUniversalIdentifier,
direction: inputSort.direction ?? existingSort.direction,
updatedAt: now,
});
}
}
}
const sortsToRemove = existingViewSorts.filter(
(s) => !inputSortIds.has(s.id),
);
return { sortsToCreate, sortsToUpdate, sortsToRemove };
}
}
@@ -59,6 +59,8 @@ export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => {
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ViewExceptionCode.VIEW_WIDGET_NOT_FOUND:
throw new NotFoundError(error.message);
default: {
return assertUnreachable(error.code);
}
@@ -12,6 +12,7 @@ import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-fie
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewWidgetUpsertService } from 'src/engine/metadata-modules/view/services/view-widget-upsert.service';
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewResolver } from 'src/engine/metadata-modules/view/resolvers/view.resolver';
@@ -44,6 +45,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewResolver,
ViewQueryParamsService,
ViewToolsFactory,
ViewWidgetUpsertService,
],
exports: [
ViewService,