Move view in metadata-modules/ and create atomic folder + module for each view entity (#14990)

# Introduction
Preparing view-filter and view-group introduction in v2 core engine
Moving view from `core-modules` to `metadata-modules`

## What happened

### Created dedicated modules for each view entity:
- ViewFieldModule
- ViewFilterModule
- ViewFilterGroupModule
- ViewGroupModule
- ViewSortModule

### Each module is now completely independent with its own:
- Controller
- Resolver
- Service
- Entity

### Created dedicated abstraction metadata module folder for:
- flat-view-field
- flat-view

### Dependencies 
- Eleminated circular dep on ViewModule to all others ones
- Granular import not importing the whole viewModule anymore everywhere


close https://github.com/twentyhq/core-team-issues/issues/1703
This commit is contained in:
Paul Rastoin
2025-10-09 15:18:15 +02:00
committed by GitHub
parent c9a1a110e1
commit 59fbe35a8c
198 changed files with 877 additions and 765 deletions
@@ -19,13 +19,13 @@ import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-met
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { AssignIfIsGivenFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-if-is-given-field-metadata-type.type';
import { AssignTypeIfIsMorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-type-if-is-morph-or-relation-field-metadata-type.type';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
@Entity('fieldMetadata')
@Check(
@@ -11,7 +11,6 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CoreViewModule } from 'src/engine/core-modules/view/view.module';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
@@ -31,6 +30,10 @@ import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewGroupModule } from 'src/engine/metadata-modules/view-group/view-group.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@@ -61,7 +64,10 @@ import { FieldMetadataService } from './services/field-metadata.service';
TypeORMModule,
ActorModule,
FeatureFlagModule,
CoreViewModule,
ViewModule,
ViewFieldModule,
ViewFilterModule,
ViewGroupModule,
PermissionsModule,
WorkspaceMetadataCacheModule,
WorkspaceMigrationV2Module,
@@ -1,11 +1,11 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { type ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service';
import { type ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service';
import { type ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service';
import { type ViewService } from 'src/engine/core-modules/view/services/view.service';
import { type 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 { type ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { type ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { type ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { type ViewService } from 'src/engine/metadata-modules/view/services/view.service';
type GetOptionsDifferencesTestContext = EachTestingContext<{
oldOptions: FieldMetadataDefaultOption[];
@@ -4,13 +4,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { settings } from 'src/engine/constants/settings';
import { ViewGroupEntity } from 'src/engine/core-modules/view/entities/view-group.entity';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/core-modules/view/enums/view-key.enum';
import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service';
import { ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service';
import { ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service';
import { ViewService } from 'src/engine/core-modules/view/services/view.service';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
@@ -22,6 +15,13 @@ import {
} 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 { type SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
type Differences<T> = {
@@ -6,7 +6,6 @@ import {
import { type AllFlatEntityMaps } from 'src/engine/core-modules/common/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/core-modules/common/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { FLAT_OBJECT_METADATA_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-properties-to-compare.constant';
@@ -15,6 +14,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
import { recomputeIndexAfterFlatObjectMetadataSingularNameUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-index-after-flat-object-metadata-singular-name-update.util';
import { recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util';
import { renameRelatedMorphFieldOnObjectNamesUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/rename-related-morph-field-on-object-names-update.util';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { type UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import {
@@ -3,8 +3,8 @@ import { v4 } from 'uuid';
import { type AllFlatEntityMaps } from 'src/engine/core-modules/common/types/all-flat-entity-maps.type';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatViewField } from 'src/engine/core-modules/view/flat-view/types/flat-view-field.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
type RecomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdateArgs = {
@@ -0,0 +1,8 @@
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
export const FLAT_VIEW_FIELD_EDITABLE_PROPERTIES = [
'isVisible',
'size',
'position',
'aggregateOperation',
] as const satisfies (keyof FlatViewField)[];
@@ -0,0 +1,7 @@
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
export const FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE = [
...FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
'deletedAt',
] as const satisfies (keyof FlatViewField)[];
@@ -0,0 +1,7 @@
import { type ViewFieldEntityRelationProperties } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
export const VIEW_FIELD_ENTITY_RELATION_PROPERTIES = [
'fieldMetadata',
'view',
'workspace',
] as const satisfies ViewFieldEntityRelationProperties[];
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { EMPTY_FLAT_ENTITY_MAPS } from 'src/engine/core-modules/common/constant/empty-flat-entity-maps.constant';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { fromViewFieldEntityToFlatViewField } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-entity-to-flat-view-field.util';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { WorkspaceFlatMapCache } from 'src/engine/workspace-flat-map-cache/decorators/workspace-flat-map-cache.decorator';
import { WorkspaceFlatMapCacheService } from 'src/engine/workspace-flat-map-cache/services/workspace-flat-map-cache.service';
@Injectable()
@WorkspaceFlatMapCache('flatViewFieldMaps')
export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceFlatMapCacheService<FlatViewFieldMaps> {
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
cacheStorageService: CacheStorageService,
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
) {
super(cacheStorageService);
}
protected async computeFlatMap({
workspaceId,
}: {
workspaceId: string;
}): Promise<FlatViewFieldMaps> {
const existingViewFields = await this.viewFieldRepository.find({
where: {
workspaceId,
},
withDeleted: true,
});
return existingViewFields.reduce((flatViewFieldMaps, viewFieldEntity) => {
const flatViewField = fromViewFieldEntityToFlatViewField(viewFieldEntity);
return addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: flatViewField,
flatEntityMaps: flatViewFieldMaps,
});
}, EMPTY_FLAT_ENTITY_MAPS);
}
}
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/core-modules/common/types/flat-entity-maps.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
export type FlatViewFieldMaps = FlatEntityMaps<FlatViewField>;
@@ -0,0 +1,4 @@
import { type FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-properties-to-compare.constant';
export type FlatViewFieldPropertiesToCompare =
(typeof FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE)[number];
@@ -0,0 +1,18 @@
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
export type ViewFieldEntityRelationProperties =
ExtractRecordTypeOrmRelationProperties<
ViewFieldEntity,
FieldMetadataEntity | ViewEntity | Workspace
>;
export type FlatViewField = Omit<
ViewFieldEntity,
ViewFieldEntityRelationProperties
> & {
universalIdentifier: string;
};
@@ -0,0 +1,59 @@
import diff from 'microdiff';
import { type FromTo } from 'twenty-shared/types';
import { FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-properties-to-compare.constant';
import { type FlatViewFieldPropertiesToCompare } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-properties-to-compare.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type UpdateViewFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-view-field-action-v2.type';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
type GetWorkspaceMigrationUpdateViewFieldActionArgs = FromTo<
FlatViewField,
'FlatViewField'
>;
export const compareTwoFlatViewField = ({
fromFlatViewField,
toFlatViewField,
}: GetWorkspaceMigrationUpdateViewFieldActionArgs) => {
const transformMetadataForComparisonParameters = {
shouldIgnoreProperty: (property: string) =>
!FLAT_VIEW_FIELD_PROPERTIES_TO_COMPARE.includes(
property as FlatViewFieldPropertiesToCompare,
),
propertiesToStringify: [],
};
const fromCompare = transformMetadataForComparison(
fromFlatViewField,
transformMetadataForComparisonParameters,
);
const toCompare = transformMetadataForComparison(
toFlatViewField,
transformMetadataForComparisonParameters,
);
const flatViewFieldDifferences = diff(fromCompare, toCompare);
return flatViewFieldDifferences.flatMap<
UpdateViewFieldAction['updates'][number]
>((difference) => {
switch (difference.type) {
case 'CHANGE': {
const { oldValue, path, value } = difference;
const property = path[0] as FlatViewFieldPropertiesToCompare;
return {
from: oldValue,
to: value,
property,
};
}
case 'CREATE':
case 'REMOVE':
default: {
// Should never occurs, we should only provide null never undefined and so on
return [];
}
}
});
};
@@ -0,0 +1,38 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
export const fromCreateViewFieldInputToFlatViewFieldToCreate = ({
createViewFieldInput: rawCreateViewFieldInput,
workspaceId,
}: {
createViewFieldInput: CreateViewFieldInput;
workspaceId: string;
}): FlatViewField => {
const { fieldMetadataId, viewId, ...createViewFieldInput } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreateViewFieldInput,
['aggregateOperation', 'fieldMetadataId', 'id', 'viewId'],
);
const createdAt = new Date();
const viewFieldId = createViewFieldInput.id ?? v4();
return {
id: viewFieldId,
fieldMetadataId,
viewId,
workspaceId,
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: viewFieldId,
isVisible: createViewFieldInput.isVisible ?? true,
size: createViewFieldInput.size ?? DEFAULT_VIEW_FIELD_SIZE,
position: createViewFieldInput.position ?? 0,
aggregateOperation: createViewFieldInput.aggregateOperation ?? null,
};
};
@@ -0,0 +1,40 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
import {
ViewFieldException,
ViewFieldExceptionCode,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
export const fromDeleteViewFieldInputToFlatViewFieldOrThrow = ({
deleteViewFieldInput: rawDeleteViewFieldInput,
flatViewFieldMaps,
}: {
deleteViewFieldInput: DeleteViewFieldInput;
flatViewFieldMaps: FlatViewFieldMaps;
}): FlatViewField => {
const { id: viewFieldId } = extractAndSanitizeObjectStringFields(
rawDeleteViewFieldInput,
['id'],
);
const existingFlatViewFieldToDelete = flatViewFieldMaps.byId[viewFieldId];
if (!isDefined(existingFlatViewFieldToDelete)) {
throw new ViewFieldException(
t`View field to delete not found`,
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
return {
...existingFlatViewFieldToDelete,
deletedAt: new Date(),
};
};
@@ -0,0 +1,37 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
import {
ViewFieldException,
ViewFieldExceptionCode,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
export const fromDestroyViewFieldInputToFlatViewFieldOrThrow = ({
destroyViewFieldInput,
flatViewFieldMaps,
}: {
destroyViewFieldInput: DestroyViewFieldInput;
flatViewFieldMaps: FlatViewFieldMaps;
}): FlatViewField => {
const { id: viewFieldId } = extractAndSanitizeObjectStringFields(
destroyViewFieldInput,
['id'],
);
const existingFlatViewFieldToDestroy = flatViewFieldMaps.byId[viewFieldId];
if (!isDefined(existingFlatViewFieldToDestroy)) {
throw new ViewFieldException(
t`View field to destroy not found`,
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
return existingFlatViewFieldToDestroy;
};
@@ -0,0 +1,50 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import {
ViewFieldException,
ViewFieldExceptionCode,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
updateViewFieldInput: rawUpdateViewFieldInput,
flatViewFieldMaps,
}: {
updateViewFieldInput: UpdateViewFieldInput;
flatViewFieldMaps: FlatViewFieldMaps;
}): FlatViewField => {
const { id: viewFieldToUpdateId } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawUpdateViewFieldInput,
['id'],
);
const existingFlatViewFieldToUpdate =
flatViewFieldMaps.byId[viewFieldToUpdateId];
if (!isDefined(existingFlatViewFieldToUpdate)) {
throw new ViewFieldException(
t`View field to update not found`,
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewFieldInput.update,
FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatViewFieldToUpdate,
properties: FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
});
};
@@ -0,0 +1,21 @@
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { VIEW_FIELD_ENTITY_RELATION_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/view-field-entity-relation-properties.constant';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
export const fromViewFieldEntityToFlatViewField = (
viewFieldEntity: ViewFieldEntity,
): FlatViewField => {
const viewFieldEntityWithoutRelations = removePropertiesFromRecord(
viewFieldEntity,
VIEW_FIELD_ENTITY_RELATION_PROPERTIES,
);
return {
...viewFieldEntityWithoutRelations,
universalIdentifier:
viewFieldEntityWithoutRelations.universalIdentifier ??
viewFieldEntityWithoutRelations.id,
};
};
@@ -0,0 +1,17 @@
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
export const isViewFieldInLowestPosition = ({
flatViewField,
otherFlatViewFields,
}: {
otherFlatViewFields: FlatViewField[];
flatViewField: FlatViewField;
}) => {
if (otherFlatViewFields.length === 0) {
return true;
}
const positions = otherFlatViewFields.map(({ position }) => position);
const lowestPosition = Math.min(...positions);
return flatViewField.position < lowestPosition;
};
@@ -0,0 +1,15 @@
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
export const FLAT_VIEW_EDITABLE_PROPERTIES = [
'name',
'type',
'icon',
'position',
'isCompact',
'openRecordIn',
'kanbanAggregateOperation',
'kanbanAggregateOperationFieldMetadataId',
'anyFieldFilterValue',
'calendarLayout',
'calendarFieldMetadataId',
] as const satisfies (keyof FlatView)[];
@@ -0,0 +1,8 @@
import { FLAT_VIEW_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view/constants/flat-view-editable-properties.constant';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
export const FLAT_VIEW_PROPERTIES_TO_COMPARE = [
'key',
'deletedAt',
...FLAT_VIEW_EDITABLE_PROPERTIES,
] as const satisfies (keyof FlatView)[];
@@ -0,0 +1,11 @@
import { type ViewEntityRelationProperties } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
export const VIEW_ENTITY_RELATION_PROPERTIES = [
'objectMetadata',
'viewFields',
'viewFilters',
'viewFilterGroups',
'viewGroups',
'viewSorts',
'workspace',
] as const satisfies ViewEntityRelationProperties[];
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceFlatViewMapCacheService } from 'src/engine/metadata-modules/flat-view/services/workspace-flat-view-map-cache.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Module({
imports: [TypeOrmModule.forFeature([ViewEntity])],
providers: [WorkspaceFlatViewMapCacheService],
exports: [WorkspaceFlatViewMapCacheService],
})
export class FlatViewModule {}
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { EMPTY_FLAT_ENTITY_MAPS } from 'src/engine/core-modules/common/constant/empty-flat-entity-maps.constant';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { fromViewEntityToFlatView } from 'src/engine/metadata-modules/flat-view/utils/from-view-entity-to-flat-view.util';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceFlatMapCache } from 'src/engine/workspace-flat-map-cache/decorators/workspace-flat-map-cache.decorator';
import { WorkspaceFlatMapCacheService } from 'src/engine/workspace-flat-map-cache/services/workspace-flat-map-cache.service';
@Injectable()
@WorkspaceFlatMapCache('flatViewMaps')
export class WorkspaceFlatViewMapCacheService extends WorkspaceFlatMapCacheService<FlatViewMaps> {
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
cacheStorageService: CacheStorageService,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
) {
super(cacheStorageService);
}
protected async computeFlatMap({
workspaceId,
}: {
workspaceId: string;
}): Promise<FlatViewMaps> {
const views = await this.viewRepository.find({
where: {
workspaceId,
},
select: {
viewFields: {
id: true,
},
},
relations: ['viewFields'],
withDeleted: true,
});
return views.reduce((flatViewMaps, viewEntity) => {
const flatView = fromViewEntityToFlatView(viewEntity);
return addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: flatView,
flatEntityMaps: flatViewMaps,
});
}, EMPTY_FLAT_ENTITY_MAPS);
}
}
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/core-modules/common/types/flat-entity-maps.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
export type FlatViewMaps = FlatEntityMaps<FlatView>;
@@ -0,0 +1,4 @@
import { type FLAT_VIEW_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-view/constants/flat-view-properties-to-compare.constant';
export type FlatViewPropertiesToCompare =
(typeof FLAT_VIEW_PROPERTIES_TO_COMPARE)[number];
@@ -0,0 +1,26 @@
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { type ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { type ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { type ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
export type ViewEntityRelationProperties =
ExtractRecordTypeOrmRelationProperties<
ViewEntity,
| ObjectMetadataEntity
| ViewFieldEntity
| ViewSortEntity
| ViewFilterEntity
| ViewGroupEntity
| ViewFilterGroupEntity
| Workspace
>;
export type FlatView = Omit<ViewEntity, ViewEntityRelationProperties> & {
universalIdentifier: string;
viewFieldIds: string[];
};
@@ -0,0 +1,56 @@
import diff from 'microdiff';
import { type FromTo } from 'twenty-shared/types';
import { FLAT_VIEW_PROPERTIES_TO_COMPARE } from 'src/engine/metadata-modules/flat-view/constants/flat-view-properties-to-compare.constant';
import { type FlatViewPropertiesToCompare } from 'src/engine/metadata-modules/flat-view/types/flat-view-properties-to-compare.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type UpdateViewAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-view-action-v2.type';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
type GetWorkspaceMigrationUpdateViewActionArgs = FromTo<FlatView, 'FlatView'>;
export const compareTwoFlatView = ({
fromFlatView,
toFlatView,
}: GetWorkspaceMigrationUpdateViewActionArgs) => {
const transformMetadataForComparisonParameters = {
shouldIgnoreProperty: (property: string) =>
!FLAT_VIEW_PROPERTIES_TO_COMPARE.includes(
property as FlatViewPropertiesToCompare,
),
propertiesToStringify: [],
};
const fromCompare = transformMetadataForComparison(
fromFlatView,
transformMetadataForComparisonParameters,
);
const toCompare = transformMetadataForComparison(
toFlatView,
transformMetadataForComparisonParameters,
);
const flatViewDifferences = diff(fromCompare, toCompare);
return flatViewDifferences.flatMap<UpdateViewAction['updates'][number]>(
(difference) => {
switch (difference.type) {
case 'CHANGE': {
const { oldValue, path, value } = difference;
const property = path[0] as FlatViewPropertiesToCompare;
return {
from: oldValue,
to: value,
property,
};
}
case 'CREATE':
case 'REMOVE':
default: {
// Should never occurs, we should only provide null never undefined and so on
return [];
}
}
},
);
};
@@ -0,0 +1,48 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input';
import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
export const fromCreateViewInputToFlatViewToCreate = ({
createViewInput: rawCreateViewInput,
workspaceId,
}: {
createViewInput: CreateViewInput;
workspaceId: string;
}): FlatView => {
const { objectMetadataId, ...createViewInput } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreateViewInput,
['id', 'name', 'objectMetadataId'],
);
const createdAt = new Date();
return {
id: createViewInput.id ?? v4(),
objectMetadataId,
workspaceId,
name: createViewInput.name,
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
isCustom: true,
anyFieldFilterValue: createViewInput.anyFieldFilterValue ?? null,
calendarFieldMetadataId: createViewInput.calendarFieldMetadataId ?? null,
calendarLayout: createViewInput.calendarLayout ?? null,
icon: createViewInput.icon,
isCompact: createViewInput.isCompact ?? false,
kanbanAggregateOperation: createViewInput.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataId:
createViewInput.kanbanAggregateOperationFieldMetadataId ?? null,
key: createViewInput.key ?? null,
openRecordIn: createViewInput.openRecordIn ?? ViewOpenRecordIn.SIDE_PANEL,
position: createViewInput.position ?? 0,
type: createViewInput.type ?? ViewType.TABLE,
universalIdentifier: v4(),
viewFieldIds: [],
};
};
@@ -0,0 +1,40 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type DeleteViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/delete-view.input';
import {
ViewException,
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
export const fromDeleteViewInputToFlatViewOrThrow = ({
deleteViewInput: rawDeleteViewInput,
flatViewMaps,
}: {
deleteViewInput: DeleteViewInput;
flatViewMaps: FlatViewMaps;
}): FlatView => {
const { id: viewId } = extractAndSanitizeObjectStringFields(
rawDeleteViewInput,
['id'],
);
const existingFlatViewToDelete = flatViewMaps.byId[viewId];
if (!isDefined(existingFlatViewToDelete)) {
throw new ViewException(
t`View to delete not found`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return {
...existingFlatViewToDelete,
deletedAt: new Date(),
};
};
@@ -0,0 +1,37 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type DestroyViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/destroy-view.input';
import {
ViewException,
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
export const fromDestroyViewInputToFlatViewOrThrow = ({
destroyViewInput: rawDestroyViewInput,
flatViewMaps,
}: {
destroyViewInput: DestroyViewInput;
flatViewMaps: FlatViewMaps;
}): FlatView => {
const { id: viewId } = extractAndSanitizeObjectStringFields(
rawDestroyViewInput,
['id'],
);
const existingFlatViewToDestroy = flatViewMaps.byId[viewId];
if (!isDefined(existingFlatViewToDestroy)) {
throw new ViewException(
t`View to destroy not found`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
return existingFlatViewToDestroy;
};
@@ -0,0 +1,38 @@
import { v4 } from 'uuid';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
export const fromPartialFlatViewToFlatViewWithDefault = (
partialFlatView: Partial<FlatView>,
): FlatView => {
const createdAt = new Date();
const viewId = partialFlatView.id ?? v4();
return {
...partialFlatView,
id: viewId,
name: partialFlatView.name ?? '',
objectMetadataId: partialFlatView.objectMetadataId ?? '',
type: partialFlatView.type ?? ViewType.TABLE,
key: partialFlatView.key ?? null,
icon: partialFlatView.icon ?? '',
position: partialFlatView.position ?? 0,
isCompact: partialFlatView.isCompact ?? false,
isCustom: partialFlatView.isCustom ?? false,
openRecordIn: partialFlatView.openRecordIn ?? ViewOpenRecordIn.SIDE_PANEL,
kanbanAggregateOperation: partialFlatView.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataId:
partialFlatView.kanbanAggregateOperationFieldMetadataId ?? null,
workspaceId: partialFlatView.workspaceId ?? '',
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
anyFieldFilterValue: partialFlatView.anyFieldFilterValue ?? null,
universalIdentifier: partialFlatView.universalIdentifier ?? viewId,
viewFieldIds: partialFlatView.viewFieldIds ?? [],
calendarLayout: partialFlatView.calendarLayout ?? null,
calendarFieldMetadataId: partialFlatView.calendarFieldMetadataId ?? null,
};
};
@@ -0,0 +1,50 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { FLAT_VIEW_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view/constants/flat-view-editable-properties.constant';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
import {
ViewException,
ViewExceptionCode,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
updateViewInput: rawUpdateViewInput,
flatViewMaps,
}: {
updateViewInput: UpdateViewInput;
flatViewMaps: FlatViewMaps;
}): FlatView => {
const { id: viewToUpdateId } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawUpdateViewInput,
['id'],
);
const existingFlatViewToUpdate = flatViewMaps.byId[viewToUpdateId];
if (!isDefined(existingFlatViewToUpdate)) {
throw new ViewException(
t`View to update not found`,
ViewExceptionCode.VIEW_NOT_FOUND,
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewInput,
FLAT_VIEW_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatViewToUpdate,
properties: FLAT_VIEW_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
});
};
@@ -0,0 +1,20 @@
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { VIEW_ENTITY_RELATION_PROPERTIES } from 'src/engine/metadata-modules/flat-view/constants/view-entity-relation-properties.constant';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
export const fromViewEntityToFlatView = (viewEntity: ViewEntity): FlatView => {
const viewEntityWithoutRelations = removePropertiesFromRecord(
viewEntity,
VIEW_ENTITY_RELATION_PROPERTIES,
);
return {
...viewEntityWithoutRelations,
universalIdentifier:
viewEntityWithoutRelations.universalIdentifier ??
viewEntityWithoutRelations.id,
viewFieldIds: viewEntity.viewFields.map((viewField) => viewField.id) ?? [],
};
};
@@ -9,12 +9,13 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
@Module({
imports: [
@@ -25,6 +26,7 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
ServerlessFunctionModule,
ServerlessFunctionLayerModule,
AgentModule,
ViewModule,
WorkspaceMetadataVersionModule,
WorkspaceMigrationModule,
RemoteServerModule,
@@ -42,6 +44,7 @@ import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serve
SearchFieldMetadataModule,
ServerlessFunctionModule,
AgentModule,
ViewModule,
RemoteServerModule,
RoleModule,
PermissionsModule,
@@ -10,17 +10,15 @@ import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/core-modul
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { getSubFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/get-sub-flat-entity-maps-or-throw.util';
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
import { ViewKey } from 'src/engine/core-modules/view/enums/view-key.enum';
import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum';
import { FlatView } from 'src/engine/core-modules/view/flat-view/types/flat-view.type';
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/core-modules/view/flat-view/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/core-modules/view/flat-view/utils/from-create-view-input-to-flat-view-to-create.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util';
import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util';
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
@@ -29,6 +27,8 @@ import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
@@ -13,13 +13,13 @@ import {
import { type WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity('objectMetadata')
@Unique('IDX_OBJECT_METADATA_NAME_SINGULAR_WORKSPACE_ID_UNIQUE', [
@@ -12,8 +12,6 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { CoreViewModule } from 'src/engine/core-modules/view/view.module';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -37,6 +35,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { RemoteTableRelationsModule } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.module';
import { SearchVectorModule } from 'src/engine/metadata-modules/search-vector/search-vector.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@@ -71,7 +72,8 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
WorkspaceDataSourceModule,
FeatureFlagModule,
WorkspaceMigrationV2Module,
CoreViewModule,
ViewModule,
ViewFieldModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
services: [
@@ -4,15 +4,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ViewDTO } from 'src/engine/core-modules/view/dtos/view.dto';
import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/core-modules/view/enums/view-key.enum';
import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum';
import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service';
import { ViewService } from 'src/engine/core-modules/view/services/view.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
import { type FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
ViewFieldException,
ViewFieldExceptionCode,
ViewFieldExceptionMessageKey,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFieldRestApiExceptionFilter } from 'src/engine/metadata-modules/view-field/filters/view-field-rest-api-exception.filter';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
@Controller('rest/metadata/viewFields')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewFieldRestApiExceptionFilter)
export class ViewFieldController {
constructor(private readonly viewFieldService: ViewFieldService) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('viewId') viewId?: string,
): Promise<ViewFieldEntity[]> {
if (viewId) {
return this.viewFieldService.findByViewId(workspace.id, viewId);
}
return this.viewFieldService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFieldEntity> {
const viewField = await this.viewFieldService.findById(id, workspace.id);
if (!isDefined(viewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
),
},
);
}
return viewField;
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdateViewFieldInput['update'],
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFieldEntity> {
const updatedViewField = await this.viewFieldService.update(
id,
workspace.id,
input,
);
return updatedViewField;
}
@Post()
async create(
@Body() input: CreateViewFieldInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFieldEntity> {
return this.viewFieldService.create({
...input,
workspaceId: workspace.id,
});
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<{ success: boolean }> {
const deletedViewField = await this.viewFieldService.delete(
id,
workspace.id,
);
return { success: isDefined(deletedViewField) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}
@@ -0,0 +1,48 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsUUID,
} from 'class-validator';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateViewFieldInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@IsOptional()
@IsBoolean()
@Field({ nullable: true, defaultValue: true })
isVisible?: boolean;
@IsOptional()
@IsNumber()
@Field({ nullable: true, defaultValue: 0 })
size?: number;
@IsOptional()
@IsNumber()
@Field({ nullable: true, defaultValue: 0 })
position?: number;
@IsOptional()
@IsEnum(AggregateOperations)
@Field(() => AggregateOperations, { nullable: true })
aggregateOperation?: AggregateOperations;
}
@@ -0,0 +1,15 @@
import { InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class DeleteViewFieldInput {
@IDField(() => UUIDScalarType, {
description: 'The id of the view field to delete.',
})
@IsUUID()
id: string;
}
@@ -0,0 +1,15 @@
import { InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class DestroyViewFieldInput {
@IDField(() => UUIDScalarType, {
description: 'The id of the view field to destroy.',
})
@IsUUID()
id: string;
}
@@ -0,0 +1,55 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
class UpdateViewFieldInputUpdates {
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isVisible?: boolean;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
size?: number;
@IsOptional()
@IsNumber()
@Field({ nullable: true })
position?: number;
@IsOptional()
@IsEnum(AggregateOperations)
@Field(() => AggregateOperations, { nullable: true })
aggregateOperation?: AggregateOperations;
}
@InputType()
export class UpdateViewFieldInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType, {
description: 'The id of the view field to update',
})
id: string;
@Type(() => UpdateViewFieldInputUpdates)
@ValidateNested()
@Field(() => UpdateViewFieldInputUpdates, {
description: 'The view field to update',
})
update: UpdateViewFieldInputUpdates;
}
@@ -0,0 +1,44 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
registerEnumType(AggregateOperations, { name: 'AggregateOperations' });
@ObjectType('CoreViewField')
export class ViewFieldDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: false, defaultValue: true })
isVisible: boolean;
@Field({ nullable: false, defaultValue: 0 })
size: number;
@Field({ nullable: false, defaultValue: 0 })
position: number;
@Field(() => AggregateOperations, { nullable: true })
aggregateOperation?: AggregateOperations | null;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,93 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity({ name: 'viewField', schema: 'core' })
@Index('IDX_VIEW_FIELD_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FIELD_VIEW_ID', ['viewId'], {
where: '"deletedAt" IS NULL',
})
@Index(
'IDX_VIEW_FIELD_FIELD_METADATA_ID_VIEW_ID_UNIQUE',
['fieldMetadataId', 'viewId'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
export class ViewFieldEntity
extends SyncableEntity
implements Required<ViewFieldEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
fieldMetadataId: string;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@Column({ nullable: false, default: true })
isVisible: boolean;
@Column({ nullable: false, type: 'int', default: 0 })
size: number;
@Column({ nullable: false, type: 'double precision', default: 0 })
position: number;
@Column({
type: 'enum',
enum: Object.values(AggregateOperations),
nullable: true,
default: null,
})
aggregateOperation: AggregateOperations | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@ManyToOne(() => ViewEntity, (view) => view.viewFields, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
}
@@ -0,0 +1,66 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export class ViewFieldException extends CustomException {
declare code: ViewFieldExceptionCode;
constructor(
message: string,
code: ViewFieldExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum ViewFieldExceptionCode {
VIEW_FIELD_NOT_FOUND = 'VIEW_FIELD_NOT_FOUND',
INVALID_VIEW_FIELD_DATA = 'INVALID_VIEW_FIELD_DATA',
}
export enum ViewFieldExceptionMessageKey {
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
VIEW_FIELD_NOT_FOUND = 'VIEW_FIELD_NOT_FOUND',
INVALID_VIEW_FIELD_DATA = 'INVALID_VIEW_FIELD_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
VIEW_FIELD_ALREADY_EXISTS = 'VIEW_FIELD_ALREADY_EXISTS',
}
export const generateViewFieldExceptionMessage = (
key: ViewFieldExceptionMessageKey,
id?: string,
) => {
switch (key) {
case ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return 'WorkspaceId is required';
case ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED:
return 'ViewId is required';
case ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND:
return `View field${id ? ` (id: ${id})` : ''} not found`;
case ViewFieldExceptionMessageKey.INVALID_VIEW_FIELD_DATA:
return `Invalid view field data${id ? ` for view field id: ${id}` : ''}`;
case ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return 'FieldMetadataId is required';
case ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS:
return 'View field already exists';
default:
assertUnreachable(key);
}
};
export const generateViewFieldUserFriendlyExceptionMessage = (
key: ViewFieldExceptionMessageKey,
) => {
switch (key) {
case ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return t`WorkspaceId is required to create a view field.`;
case ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED:
return t`ViewId is required to create a view field.`;
case ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return t`FieldMetadataId is required to create a view field.`;
case ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS:
return t`View field already exists.`;
}
};
@@ -0,0 +1,48 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
ViewFieldException,
ViewFieldExceptionCode,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(ViewFieldException)
export class ViewFieldRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ViewFieldException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
@@ -0,0 +1,144 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@Resolver(() => ViewFieldDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewFieldResolver {
constructor(
private readonly viewFieldService: ViewFieldService,
private readonly featureFlagService: FeatureFlagService,
private readonly viewFieldV2Service: ViewFieldV2Service,
) {}
@Query(() => [ViewFieldDTO])
async getCoreViewFields(
@Args('viewId', { type: () => String }) viewId: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFieldEntity[]> {
return this.viewFieldService.findByViewId(workspace.id, viewId);
}
@Query(() => ViewFieldDTO, { nullable: true })
async getCoreViewField(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFieldEntity | null> {
return this.viewFieldService.findById(id, workspace.id);
}
@Mutation(() => ViewFieldDTO)
async updateCoreViewField(
@Args('input') updateViewFieldInput: UpdateViewFieldInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.updateOne({
updateViewFieldInput,
workspaceId,
});
}
return this.viewFieldService.update(
updateViewFieldInput.id,
workspaceId,
updateViewFieldInput.update,
);
}
@Mutation(() => ViewFieldDTO)
async createCoreViewField(
@Args('input') createViewFieldInput: CreateViewFieldInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.createOne({
createViewFieldInput,
workspaceId,
});
}
return this.viewFieldService.create({
...createViewFieldInput,
workspaceId,
});
}
@Mutation(() => ViewFieldDTO)
async deleteCoreViewField(
@Args('input') deleteViewFieldInput: DeleteViewFieldInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.deleteOne({
deleteViewFieldInput,
workspaceId,
});
}
const deletedViewField = await this.viewFieldService.delete(
deleteViewFieldInput.id,
workspaceId,
);
return deletedViewField;
}
@Mutation(() => ViewFieldDTO)
async destroyCoreViewField(
@Args('input') destroyViewFieldInput: DestroyViewFieldInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<ViewFieldDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return await this.viewFieldV2Service.destroyOne({
destroyViewFieldInput,
workspaceId,
});
}
const deletedViewField = await this.viewFieldService.destroy(
destroyViewFieldInput.id,
workspaceId,
);
return deletedViewField;
}
}
@@ -0,0 +1,526 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UserInputError } from 'apollo-server-core';
import { type Repository } from 'typeorm';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
ViewFieldExceptionMessageKey,
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewFieldService', () => {
let viewFieldService: ViewFieldService;
let viewFieldRepository: Repository<ViewFieldEntity>;
const mockViewField = {
id: 'view-field-id',
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
position: 0,
isVisible: true,
size: 100,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewFieldEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewFieldService,
{
provide: getRepositoryToken(ViewFieldEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
{
provide: getRepositoryToken(ViewEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
],
}).compile();
viewFieldService = module.get<ViewFieldService>(ViewFieldService);
viewFieldRepository = module.get<Repository<ViewFieldEntity>>(
getRepositoryToken(ViewFieldEntity),
);
});
it('should be defined', () => {
expect(viewFieldService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view fields for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewFields = [mockViewField];
jest
.spyOn(viewFieldRepository, 'find')
.mockResolvedValue(expectedViewFields);
const result = await viewFieldService.findByWorkspaceId(workspaceId);
expect(viewFieldRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewFields);
});
});
describe('findByViewId', () => {
it('should return view fields for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewFields = [mockViewField];
jest
.spyOn(viewFieldRepository, 'find')
.mockResolvedValue(expectedViewFields);
const result = await viewFieldService.findByViewId(workspaceId, viewId);
expect(viewFieldRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewFields);
});
});
describe('findById', () => {
it('should return a view field by id', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFieldRepository, 'findOne')
.mockResolvedValue(mockViewField);
const result = await viewFieldService.findById(id, workspaceId);
expect(viewFieldRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(mockViewField);
});
it('should return null when view field is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldRepository, 'findOne').mockResolvedValue(null);
const result = await viewFieldService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewFieldData = {
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
position: 0,
isVisible: true,
size: 100,
};
it('should throw exception when workspaceId is missing', async () => {
const invalidData = { ...validViewFieldData, workspaceId: undefined };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewFieldData, viewId: undefined };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when fieldMetadataId is missing', async () => {
const invalidData = { ...validViewFieldData, fieldMetadataId: undefined };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
),
);
});
it('should throw exception if position is lower than label metadata identifier', async () => {
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [
labelIdentifierViewField,
{ ...mockViewField, position: 1 },
],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === mockViewField.id) {
return Promise.resolve(mockViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
const invalidData = { ...validViewFieldData, position: -1 };
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
),
);
});
});
describe('update', () => {
it('should update a view field successfully', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
const updateData = { position: 1 };
const updatedViewField = { ...mockViewField, ...updateData };
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId: mockViewField.fieldMetadataId,
},
viewFields: [mockViewField],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
jest
.spyOn(viewFieldRepository, 'save')
.mockResolvedValue(updatedViewField);
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
const result = await viewFieldService.update(id, workspaceId, updateData);
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFieldRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({ ...mockViewField, ...updatedViewField });
});
it('should throw exception when view field is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { position: 1 };
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(null);
await expect(
viewFieldService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
),
);
});
it('should throw exception when label metadata identifier is not in first position (label metadata identifier field update case)', async () => {
const workspaceId = 'workspace-id';
const updateData = { position: 2 };
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [
labelIdentifierViewField,
{ ...mockViewField, position: 1 },
],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === labelIdentifierViewFieldId) {
return Promise.resolve(labelIdentifierViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
await expect(
viewFieldService.update(
labelIdentifierViewFieldId,
workspaceId,
updateData,
),
).rejects.toThrow(
new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
),
);
});
it('should throw exception when label metadata identifier is not in first position (regular field update case)', async () => {
const workspaceId = 'workspace-id';
const updateData = { position: -1 };
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [
labelIdentifierViewField,
{ ...mockViewField, position: 1 },
],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === mockViewField.id) {
return Promise.resolve(mockViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
await expect(
viewFieldService.update(mockViewField.id, workspaceId, updateData),
).rejects.toThrow(
new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
),
);
});
it('should throw exception when attempting to make label metadata identifier invisible', async () => {
const workspaceId = 'workspace-id';
const updateData = { isVisible: false };
const labelIdentifierFieldMetadataId =
'label-identifier-field-matadata-id';
const labelIdentifierViewFieldId =
'view-field-for-label-metadata-identifier-id';
const labelIdentifierViewField = {
...mockViewField,
id: labelIdentifierViewFieldId,
fieldMetadataId: labelIdentifierFieldMetadataId,
position: 0,
};
const mockView = {
id: 'view-id',
objectMetadata: {
labelIdentifierFieldMetadataId,
},
viewFields: [labelIdentifierViewField, mockViewField],
} as ViewEntity;
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
if (id === labelIdentifierViewFieldId) {
return Promise.resolve(labelIdentifierViewField);
}
return Promise.resolve(null);
});
jest
.spyOn(viewFieldService, 'findViewByIdWithRelations')
.mockResolvedValue(mockView);
await expect(
viewFieldService.update(
labelIdentifierViewField.id,
workspaceId,
updateData,
),
).rejects.toThrow(
new UserInputError('Label metadata identifier must stay visible.', {
userFriendlyMessage: 'Record text must stay visible.',
}),
);
});
});
describe('delete', () => {
it('should delete a view field successfully', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
jest
.spyOn(viewFieldRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await viewFieldService.delete(id, workspaceId);
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFieldRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewField);
});
it('should throw exception when view field is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(null);
await expect(viewFieldService.delete(id, workspaceId)).rejects.toThrow(
new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view field successfully', async () => {
const id = 'view-field-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
jest.spyOn(viewFieldRepository, 'delete').mockResolvedValue({} as any);
const result = await viewFieldService.destroy(id, workspaceId);
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFieldRepository.delete).toHaveBeenCalledWith(id);
expect(result).toBeDefined();
});
});
});
@@ -0,0 +1,321 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { EMPTY_FLAT_ENTITY_MAPS } from 'src/engine/core-modules/common/constant/empty-flat-entity-maps.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/delete-flat-entity-from-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { getSubFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/get-sub-flat-entity-maps-or-throw.util';
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
import { fromDeleteViewFieldInputToFlatViewFieldOrThrow } from 'src/engine/metadata-modules/flat-view-field/utils/from-delete-view-field-input-to-flat-view-field-or-throw.util';
import { fromDestroyViewFieldInputToFlatViewFieldOrThrow } from 'src/engine/metadata-modules/flat-view-field/utils/from-destroy-view-field-input-to-flat-view-field-or-throw.util';
import { fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view-field/utils/from-update-view-field-input-to-flat-view-field-to-update-or-throw.util';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class ViewFieldV2Service {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async createOne({
createViewFieldInput,
workspaceId,
}: {
createViewFieldInput: CreateViewFieldInput;
workspaceId: string;
}): Promise<ViewFieldDTO> {
const {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewMaps,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatViewFieldMaps',
'flatViewMaps',
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
],
},
);
const flatViewFieldToCreate =
fromCreateViewFieldInputToFlatViewFieldToCreate({
createViewFieldInput,
workspaceId,
});
const toFlatViewFieldMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: flatViewFieldToCreate,
flatEntityMaps: existingFlatViewFieldMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
fromToAllFlatEntityMaps: {
flatViewFieldMaps: {
from: existingFlatViewFieldMaps,
to: toFlatViewFieldMaps,
},
},
dependencyAllFlatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
flatObjectMetadataMaps,
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while creating view field',
);
}
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatViewFieldToCreate.id,
flatEntityMaps: recomputedExistingFlatViewFieldMaps,
});
}
async updateOne({
updateViewFieldInput,
workspaceId,
}: {
workspaceId: string;
updateViewFieldInput: UpdateViewFieldInput;
}): Promise<ViewFieldDTO> {
const {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatObjectMetadataMaps,
flatViewMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatViewFieldMaps',
'flatObjectMetadataMaps',
'flatViewMaps',
],
},
);
const optimisticallyUpdatedFlatView =
fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow({
flatViewFieldMaps: existingFlatViewFieldMaps,
updateViewFieldInput,
});
const toFlatViewFieldMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: optimisticallyUpdatedFlatView,
flatEntityMaps: EMPTY_FLAT_ENTITY_MAPS,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
fromToAllFlatEntityMaps: {
flatViewFieldMaps: {
from: existingFlatViewFieldMaps,
to: toFlatViewFieldMaps,
},
},
dependencyAllFlatEntityMaps: {
flatObjectMetadataMaps,
flatViewMaps,
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while updating view field',
);
}
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: optimisticallyUpdatedFlatView.id,
flatEntityMaps: recomputedExistingFlatViewFieldMaps,
});
}
async deleteOne({
deleteViewFieldInput,
workspaceId,
}: {
deleteViewFieldInput: DeleteViewFieldInput;
workspaceId: string;
}): Promise<ViewFieldDTO> {
const {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatObjectMetadataMaps,
flatViewMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatViewFieldMaps',
'flatObjectMetadataMaps',
'flatViewMaps',
],
},
);
const optimisticallyUpdatedFlatViewWithDeletedAt =
fromDeleteViewFieldInputToFlatViewFieldOrThrow({
flatViewFieldMaps: existingFlatViewFieldMaps,
deleteViewFieldInput,
});
const toFlatViewFieldMaps = replaceFlatEntityInFlatEntityMapsOrThrow({
flatEntity: optimisticallyUpdatedFlatViewWithDeletedAt,
flatEntityMaps: existingFlatViewFieldMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
fromToAllFlatEntityMaps: {
flatViewFieldMaps: {
from: existingFlatViewFieldMaps,
to: toFlatViewFieldMaps,
},
},
dependencyAllFlatEntityMaps: {
flatObjectMetadataMaps,
flatViewMaps,
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: false,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deleting view field',
);
}
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: optimisticallyUpdatedFlatViewWithDeletedAt.id,
flatEntityMaps: recomputedExistingFlatViewFieldMaps,
});
}
async destroyOne({
destroyViewFieldInput,
workspaceId,
}: {
destroyViewFieldInput: DestroyViewFieldInput;
workspaceId: string;
}): Promise<ViewFieldDTO> {
const {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewMaps: existingFlatViewMaps,
} =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps', 'flatViewMaps'],
},
);
const existingViewFieldToDelete =
fromDestroyViewFieldInputToFlatViewFieldOrThrow({
destroyViewFieldInput,
flatViewFieldMaps: existingFlatViewFieldMaps,
});
const fromFlatViewFieldMaps = getSubFlatEntityMapsOrThrow({
flatEntityIds: [existingViewFieldToDelete.id],
flatEntityMaps: existingFlatViewFieldMaps,
});
const toFlatViewFieldMaps = deleteFlatEntityFromFlatEntityMapsOrThrow({
flatEntityMaps: fromFlatViewFieldMaps,
entityToDeleteId: existingViewFieldToDelete.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
fromToAllFlatEntityMaps: {
flatViewFieldMaps: {
from: fromFlatViewFieldMaps,
to: toFlatViewFieldMaps,
},
},
dependencyAllFlatEntityMaps: {
flatViewMaps: existingFlatViewMaps,
},
buildOptions: {
isSystemBuild: false,
inferDeletionFromMissingEntities: true,
},
workspaceId,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deleting view field',
);
}
return existingViewFieldToDelete;
}
}
@@ -0,0 +1,462 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
ViewFieldExceptionMessageKey,
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewFieldService {
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<ViewFieldEntity[]> {
return this.viewFieldRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewFieldEntity[]> {
return this.viewFieldRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewFieldEntity | null> {
const viewField = await this.viewFieldRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
return viewField || null;
}
async create(
viewFieldData: Partial<ViewFieldEntity>,
): Promise<ViewFieldEntity> {
if (this.hasRequiredFields(viewFieldData)) {
try {
if (isDefined(viewFieldData.position)) {
const viewFieldDataWithPosition =
viewFieldData as typeof viewFieldData & { position: number };
await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
viewFieldDataWithPosition,
viewFieldData.workspaceId,
);
}
await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
viewFieldData,
viewFieldData.workspaceId,
);
const viewFieldDataWithPosition = await this.formatViewFieldData(
viewFieldData,
viewFieldData.workspaceId,
);
const viewField = this.viewFieldRepository.create(
viewFieldDataWithPosition,
);
const savedViewField = await this.viewFieldRepository.save(viewField);
await this.flushGraphQLCache(viewFieldData.workspaceId);
const createdViewField = await this.findById(
savedViewField.id,
viewFieldData.workspaceId,
);
if (!isDefined(createdViewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
{
userFriendlyMessage:
generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
),
},
);
}
return createdViewField;
} catch (error) {
if (
error.message.includes(
'duplicate key value violates unique constraint',
)
) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage:
generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
),
},
);
}
throw error;
}
} else {
if (!isDefined(viewFieldData.workspaceId)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewFieldData.viewId)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
{
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
);
}
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewFieldEntity>,
): Promise<ViewFieldEntity> {
const existingViewField = await this.findById(id, workspaceId);
if (!isDefined(existingViewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
const viewId = existingViewField.viewId;
if (this.updatesPosition(updateData)) {
await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
{
...updateData,
viewId,
id,
fieldMetadataId: existingViewField.fieldMetadataId,
},
workspaceId,
);
}
if (this.disablesVisibility(updateData)) {
await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
{ ...updateData, viewId, id },
workspaceId,
);
}
const updatedViewField = await this.viewFieldRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewField, ...updatedViewField };
}
async delete(id: string, workspaceId: string): Promise<ViewFieldEntity> {
const viewField = await this.findById(id, workspaceId);
if (!isDefined(viewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
await this.viewFieldRepository.softDelete(id);
await this.flushGraphQLCache(workspaceId);
return viewField;
}
async destroy(id: string, workspaceId: string): Promise<ViewFieldEntity> {
const viewField = await this.findById(id, workspaceId);
if (!isDefined(viewField)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
id,
),
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
await this.viewFieldRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return viewField;
}
private updatesPosition(
data: Partial<ViewFieldEntity>,
): data is Partial<ViewFieldEntity> & { position: number } {
return isDefined(data.position);
}
private disablesVisibility(
data: Partial<ViewFieldEntity>,
): data is Partial<ViewFieldEntity> & { isVisible: boolean } {
return data.isVisible === false;
}
private async verifyLabelMetadataIdentifierIsVisibleOrThrow(
newOrUpdatedViewField: Partial<ViewFieldEntity> & {
viewId: string;
},
workspaceId: string,
) {
const view = await this.findViewByIdWithRelations(
newOrUpdatedViewField.viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
}
const labelMetadataIdentifierFieldMetadataId =
view.objectMetadata.labelIdentifierFieldMetadataId;
const labelMetadataIdentifierViewField = view.viewFields.find(
(viewField) =>
viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
);
if (
!isDefined(labelMetadataIdentifierViewField) ||
labelMetadataIdentifierViewField.id !== newOrUpdatedViewField.id
) {
return;
}
if (newOrUpdatedViewField.isVisible === false) {
throw new UserInputError('Label metadata identifier must stay visible.', {
userFriendlyMessage: 'Record text must stay visible.',
});
}
}
private async verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
newOrUpdatedViewField: Partial<ViewFieldEntity> & { viewId: string } & {
position: number;
},
workspaceId: string,
) {
const view = await this.findViewByIdWithRelations(
newOrUpdatedViewField.viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
}
const viewFieldsWithoutUpdatedViewField = view.viewFields.filter(
(viewField) => viewField.id !== newOrUpdatedViewField?.id,
);
if (viewFieldsWithoutUpdatedViewField.length === 0) {
return;
}
const labelMetadataIdentifierFieldMetadataId =
view.objectMetadata.labelIdentifierFieldMetadataId;
if (
labelMetadataIdentifierFieldMetadataId ===
newOrUpdatedViewField.fieldMetadataId
) {
const minPositionInViewWithoutUpdatedViewField =
viewFieldsWithoutUpdatedViewField.reduce(
(minViewField, viewField) =>
viewField.position < minViewField.position
? viewField
: minViewField,
viewFieldsWithoutUpdatedViewField[0],
).position;
if (
newOrUpdatedViewField.position >=
minPositionInViewWithoutUpdatedViewField
) {
throw new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
);
}
} else {
const labelMetadataIdentifierViewFieldPosition = view.viewFields.find(
(viewField) =>
viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
)?.position;
if (
isDefined(labelMetadataIdentifierViewFieldPosition) &&
newOrUpdatedViewField.position <=
labelMetadataIdentifierViewFieldPosition
) {
throw new UserInputError(
'Label metadata identifier must keep the minimal position in the view.',
{
userFriendlyMessage:
'Record text must be in first position of the view.',
},
);
}
}
}
private async formatViewFieldData(
viewFieldData: Partial<ViewFieldEntity> & { viewId: string },
workspaceId: string,
): Promise<Partial<ViewFieldEntity>> {
if (!isDefined(viewFieldData.position)) {
const view = await this.findViewByIdWithRelations(
viewFieldData.viewId,
workspaceId,
);
if (!isDefined(view)) {
throw new Error(`View not found: ${viewFieldData.viewId}`);
}
const highestPositionInView = view.viewFields.reduce(
(maxViewField, viewField) =>
viewField.position > maxViewField.position ? viewField : maxViewField,
view.viewFields[0],
);
return { ...viewFieldData, position: highestPositionInView.position + 1 };
} else {
return viewFieldData;
}
}
private hasRequiredFields(
data: Partial<ViewFieldEntity>,
): data is Partial<ViewFieldEntity> & {
viewId: string;
fieldMetadataId: string;
workspaceId: string;
} {
return (
isDefined(data.viewId) &&
isDefined(data.fieldMetadataId) &&
isDefined(data.workspaceId)
);
}
public async findViewByIdWithRelations(
id: string,
workspaceId: string,
): Promise<ViewEntity | null> {
const view = await this.viewRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'objectMetadata', 'viewFields'],
});
return view || null;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ViewFieldController } from 'src/engine/metadata-modules/view-field/controllers/view-field.controller';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
imports: [
TypeOrmModule.forFeature([ViewFieldEntity, ViewEntity]),
WorkspaceCacheStorageModule,
FeatureFlagModule,
WorkspaceMigrationV2Module,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
controllers: [ViewFieldController],
providers: [ViewFieldService, ViewFieldResolver, ViewFieldV2Service],
exports: [ViewFieldService, ViewFieldV2Service],
})
export class ViewFieldModule {}
@@ -0,0 +1,121 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/create-view-filter-group.input';
import { UpdateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/update-view-filter-group.input';
import { type ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import {
generateViewFilterGroupExceptionMessage,
generateViewFilterGroupUserFriendlyExceptionMessage,
ViewFilterGroupException,
ViewFilterGroupExceptionCode,
ViewFilterGroupExceptionMessageKey,
} from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
import { ViewFilterGroupRestApiExceptionFilter } from 'src/engine/metadata-modules/view-filter-group/filters/view-filter-group-rest-api-exception.filter';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
@Controller('rest/metadata/viewFilterGroups')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewFilterGroupRestApiExceptionFilter)
export class ViewFilterGroupController {
constructor(
private readonly viewFilterGroupService: ViewFilterGroupService,
) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('viewId') viewId?: string,
): Promise<ViewFilterGroupDTO[]> {
if (viewId) {
return this.viewFilterGroupService.findByViewId(workspace.id, viewId);
}
return this.viewFilterGroupService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterGroupDTO> {
const viewFilterGroup = await this.viewFilterGroupService.findById(
id,
workspace.id,
);
if (!isDefined(viewFilterGroup)) {
throw new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
id,
),
ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND,
{
userFriendlyMessage:
generateViewFilterGroupUserFriendlyExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
),
},
);
}
return viewFilterGroup;
}
@Post()
async create(
@Body() input: CreateViewFilterGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterGroupDTO> {
return this.viewFilterGroupService.create({
...input,
workspaceId: workspace.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdateViewFilterGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterGroupDTO> {
const updatedViewFilterGroup = await this.viewFilterGroupService.update(
id,
workspace.id,
input,
);
return updatedViewFilterGroup;
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<{ success: boolean }> {
const deletedViewFilterGroup = await this.viewFilterGroupService.delete(
id,
workspace.id,
);
return { success: isDefined(deletedViewFilterGroup) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}
@@ -0,0 +1,25 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
@InputType()
export class CreateViewFilterGroupInput {
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@Field(() => UUIDScalarType, { nullable: true })
parentViewFilterGroupId?: string;
@Field(() => ViewFilterGroupLogicalOperator, {
nullable: true,
defaultValue: ViewFilterGroupLogicalOperator.NOT,
})
logicalOperator?: ViewFilterGroupLogicalOperator;
@Field({ nullable: true })
positionInViewFilterGroup?: number;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
}
@@ -0,0 +1,8 @@
import { InputType, PartialType } from '@nestjs/graphql';
import { CreateViewFilterGroupInput } from './create-view-filter-group.input';
@InputType()
export class UpdateViewFilterGroupInput extends PartialType(
CreateViewFilterGroupInput,
) {}
@@ -0,0 +1,43 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
registerEnumType(ViewFilterGroupLogicalOperator, {
name: 'ViewFilterGroupLogicalOperator',
});
@ObjectType('CoreViewFilterGroup')
export class ViewFilterGroupDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: true })
parentViewFilterGroupId?: string | null;
@Field(() => ViewFilterGroupLogicalOperator, {
nullable: false,
defaultValue: ViewFilterGroupLogicalOperator.NOT,
})
logicalOperator: ViewFilterGroupLogicalOperator;
@Field(() => Number, { nullable: true })
positionInViewFilterGroup?: number | null;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,90 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity({ name: 'viewFilterGroup', schema: 'core' })
@Index('IDX_VIEW_FILTER_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FILTER_GROUP_VIEW_ID', ['viewId'], {
where: '"deletedAt" IS NULL',
})
export class ViewFilterGroupEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
parentViewFilterGroupId?: string | null;
@Column({
type: 'enum',
enum: Object.values(ViewFilterGroupLogicalOperator),
nullable: false,
default: ViewFilterGroupLogicalOperator.AND,
})
logicalOperator: ViewFilterGroupLogicalOperator;
@Column({ nullable: true, type: 'double precision' })
positionInViewFilterGroup: number | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@ManyToOne(() => ViewEntity, (view) => view.viewFilterGroups, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
@OneToMany(() => ViewFilterEntity, (viewFilter) => viewFilter.viewFilterGroup)
viewFilters: Relation<ViewFilterEntity>[];
@ManyToOne(
() => ViewFilterGroupEntity,
(viewFilterGroup) => viewFilterGroup.childViewFilterGroups,
{
onDelete: 'CASCADE',
},
)
@JoinColumn({ name: 'parentViewFilterGroupId' })
parentViewFilterGroup: Relation<ViewFilterGroupEntity>;
@OneToMany(
() => ViewFilterGroupEntity,
(viewFilterGroup) => viewFilterGroup.parentViewFilterGroup,
)
childViewFilterGroups: Relation<ViewFilterGroupEntity>[];
}
@@ -0,0 +1,5 @@
export enum ViewFilterGroupLogicalOperator {
AND = 'AND',
OR = 'OR',
NOT = 'NOT',
}
@@ -0,0 +1,61 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export class ViewFilterGroupException extends CustomException {
declare code: ViewFilterGroupExceptionCode;
constructor(
message: string,
code: ViewFilterGroupExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum ViewFilterGroupExceptionCode {
VIEW_FILTER_GROUP_NOT_FOUND = 'VIEW_FILTER_GROUP_NOT_FOUND',
INVALID_VIEW_FILTER_GROUP_DATA = 'INVALID_VIEW_FILTER_GROUP_DATA',
}
export enum ViewFilterGroupExceptionMessageKey {
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
VIEW_FILTER_GROUP_NOT_FOUND = 'VIEW_FILTER_GROUP_NOT_FOUND',
INVALID_VIEW_FILTER_GROUP_DATA = 'INVALID_VIEW_FILTER_GROUP_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
}
export const generateViewFilterGroupExceptionMessage = (
key: ViewFilterGroupExceptionMessageKey,
id?: string,
) => {
switch (key) {
case ViewFilterGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return 'WorkspaceId is required';
case ViewFilterGroupExceptionMessageKey.VIEW_ID_REQUIRED:
return 'ViewId is required';
case ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND:
return `View filter group${id ? ` (id: ${id})` : ''} not found`;
case ViewFilterGroupExceptionMessageKey.INVALID_VIEW_FILTER_GROUP_DATA:
return `Invalid view filter group data${id ? ` for view filter group id: ${id}` : ''}`;
case ViewFilterGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return 'FieldMetadataId is required';
default:
assertUnreachable(key);
}
};
export const generateViewFilterGroupUserFriendlyExceptionMessage = (
key: ViewFilterGroupExceptionMessageKey,
) => {
switch (key) {
case ViewFilterGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return t`WorkspaceId is required to create a view filter group.`;
case ViewFilterGroupExceptionMessageKey.VIEW_ID_REQUIRED:
return t`ViewId is required to create a view filter group.`;
case ViewFilterGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return t`FieldMetadataId is required to create a view filter group.`;
}
};
@@ -0,0 +1,48 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
ViewFilterGroupException,
ViewFilterGroupExceptionCode,
} from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(ViewFilterGroupException)
export class ViewFilterGroupRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ViewFilterGroupException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ViewFilterGroupExceptionCode.INVALID_VIEW_FILTER_GROUP_DATA:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
@@ -0,0 +1,89 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/create-view-filter-group.input';
import { UpdateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/update-view-filter-group.input';
import { ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@Resolver(() => ViewFilterGroupDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewFilterGroupResolver {
constructor(
private readonly viewFilterGroupService: ViewFilterGroupService,
) {}
@Query(() => [ViewFilterGroupDTO])
async getCoreViewFilterGroups(
@AuthWorkspace() workspace: Workspace,
@Args('viewId', { type: () => String, nullable: true })
viewId?: string,
): Promise<ViewFilterGroupDTO[]> {
if (viewId) {
return this.viewFilterGroupService.findByViewId(workspace.id, viewId);
}
return this.viewFilterGroupService.findByWorkspaceId(workspace.id);
}
@Query(() => ViewFilterGroupDTO, { nullable: true })
async getCoreViewFilterGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterGroupDTO | null> {
return this.viewFilterGroupService.findById(id, workspace.id);
}
@Mutation(() => ViewFilterGroupDTO)
async createCoreViewFilterGroup(
@Args('input') input: CreateViewFilterGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterGroupDTO> {
return this.viewFilterGroupService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ViewFilterGroupDTO)
async updateCoreViewFilterGroup(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewFilterGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterGroupDTO> {
return this.viewFilterGroupService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
async deleteCoreViewFilterGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewFilterGroup = await this.viewFilterGroupService.delete(
id,
workspace.id,
);
return isDefined(deletedViewFilterGroup);
}
@Mutation(() => Boolean)
async destroyCoreViewFilterGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewFilterGroup = await this.viewFilterGroupService.destroy(
id,
workspace.id,
);
return isDefined(deletedViewFilterGroup);
}
}
@@ -0,0 +1,363 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import {
ViewFilterGroupException,
ViewFilterGroupExceptionCode,
ViewFilterGroupExceptionMessageKey,
generateViewFilterGroupExceptionMessage,
generateViewFilterGroupUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewFilterGroupService', () => {
let viewFilterGroupService: ViewFilterGroupService;
let viewFilterGroupRepository: Repository<ViewFilterGroupEntity>;
const mockViewFilterGroup = {
id: 'view-filter-group-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
logicalOperator: ViewFilterGroupLogicalOperator.AND,
positionInViewFilterGroup: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewFilterGroupEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewFilterGroupService,
{
provide: getRepositoryToken(ViewFilterGroupEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
],
}).compile();
viewFilterGroupService = module.get<ViewFilterGroupService>(
ViewFilterGroupService,
);
viewFilterGroupRepository = module.get<Repository<ViewFilterGroupEntity>>(
getRepositoryToken(ViewFilterGroupEntity),
);
});
it('should be defined', () => {
expect(viewFilterGroupService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view filter groups for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewFilterGroups = [mockViewFilterGroup];
jest
.spyOn(viewFilterGroupRepository, 'find')
.mockResolvedValue(expectedViewFilterGroups);
const result =
await viewFilterGroupService.findByWorkspaceId(workspaceId);
expect(viewFilterGroupRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: [
'workspace',
'view',
'viewFilters',
'parentViewFilterGroup',
'childViewFilterGroups',
],
});
expect(result).toEqual(expectedViewFilterGroups);
});
});
describe('findByViewId', () => {
it('should return view filter groups for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewFilterGroups = [mockViewFilterGroup];
jest
.spyOn(viewFilterGroupRepository, 'find')
.mockResolvedValue(expectedViewFilterGroups);
const result = await viewFilterGroupService.findByViewId(
workspaceId,
viewId,
);
expect(viewFilterGroupRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: [
'workspace',
'view',
'viewFilters',
'parentViewFilterGroup',
'childViewFilterGroups',
],
});
expect(result).toEqual(expectedViewFilterGroups);
});
});
describe('findById', () => {
it('should return a view filter group by id', async () => {
const id = 'view-filter-group-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFilterGroupRepository, 'findOne')
.mockResolvedValue(mockViewFilterGroup);
const result = await viewFilterGroupService.findById(id, workspaceId);
expect(viewFilterGroupRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: [
'workspace',
'view',
'viewFilters',
'parentViewFilterGroup',
'childViewFilterGroups',
],
});
expect(result).toEqual(mockViewFilterGroup);
});
it('should return null when view filter group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFilterGroupRepository, 'findOne').mockResolvedValue(null);
const result = await viewFilterGroupService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewFilterGroupData = {
viewId: 'view-id',
workspaceId: 'workspace-id',
logicalOperator: ViewFilterGroupLogicalOperator.AND,
positionInViewFilterGroup: 0,
};
it('should create a view filter group successfully', async () => {
jest
.spyOn(viewFilterGroupRepository, 'create')
.mockReturnValue(mockViewFilterGroup);
jest
.spyOn(viewFilterGroupRepository, 'save')
.mockResolvedValue(mockViewFilterGroup);
const result = await viewFilterGroupService.create(
validViewFilterGroupData,
);
expect(viewFilterGroupRepository.create).toHaveBeenCalledWith(
validViewFilterGroupData,
);
expect(viewFilterGroupRepository.save).toHaveBeenCalledWith(
mockViewFilterGroup,
);
expect(result).toEqual(mockViewFilterGroup);
});
it('should throw exception when workspaceId is missing', async () => {
const invalidData = {
...validViewFilterGroupData,
workspaceId: undefined,
};
await expect(viewFilterGroupService.create(invalidData)).rejects.toThrow(
new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFilterGroupExceptionCode.INVALID_VIEW_FILTER_GROUP_DATA,
{
userFriendlyMessage:
generateViewFilterGroupUserFriendlyExceptionMessage(
ViewFilterGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewFilterGroupData, viewId: undefined };
await expect(viewFilterGroupService.create(invalidData)).rejects.toThrow(
new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFilterGroupExceptionCode.INVALID_VIEW_FILTER_GROUP_DATA,
{
userFriendlyMessage:
generateViewFilterGroupUserFriendlyExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
});
describe('update', () => {
it('should update a view filter group successfully', async () => {
const id = 'view-filter-group-id';
const workspaceId = 'workspace-id';
const updateData = { logicalOperator: ViewFilterGroupLogicalOperator.OR };
const updatedViewFilterGroup = { ...mockViewFilterGroup, ...updateData };
jest
.spyOn(viewFilterGroupService, 'findById')
.mockResolvedValue(mockViewFilterGroup);
jest
.spyOn(viewFilterGroupRepository, 'save')
.mockResolvedValue(updatedViewFilterGroup);
const result = await viewFilterGroupService.update(
id,
workspaceId,
updateData,
);
expect(viewFilterGroupService.findById).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(viewFilterGroupRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({
...mockViewFilterGroup,
...updatedViewFilterGroup,
});
});
it('should throw exception when view filter group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { logicalOperator: ViewFilterGroupLogicalOperator.OR };
jest.spyOn(viewFilterGroupService, 'findById').mockResolvedValue(null);
await expect(
viewFilterGroupService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
id,
),
ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a view filter group successfully', async () => {
const id = 'view-filter-group-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFilterGroupService, 'findById')
.mockResolvedValue(mockViewFilterGroup);
jest
.spyOn(viewFilterGroupRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await viewFilterGroupService.delete(id, workspaceId);
expect(viewFilterGroupService.findById).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(viewFilterGroupRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewFilterGroup);
});
it('should throw exception when view filter group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFilterGroupService, 'findById').mockResolvedValue(null);
await expect(
viewFilterGroupService.delete(id, workspaceId),
).rejects.toThrow(
new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
id,
),
ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view filter group successfully', async () => {
const id = 'view-filter-group-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFilterGroupService, 'findById')
.mockResolvedValue(mockViewFilterGroup);
jest
.spyOn(viewFilterGroupRepository, 'delete')
.mockResolvedValue({} as any);
const result = await viewFilterGroupService.destroy(id, workspaceId);
expect(viewFilterGroupService.findById).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(viewFilterGroupRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(true);
});
});
});
@@ -0,0 +1,208 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import {
ViewFilterGroupException,
ViewFilterGroupExceptionCode,
ViewFilterGroupExceptionMessageKey,
generateViewFilterGroupExceptionMessage,
generateViewFilterGroupUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewFilterGroupService {
constructor(
@InjectRepository(ViewFilterGroupEntity)
private readonly viewFilterGroupRepository: Repository<ViewFilterGroupEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(
workspaceId: string,
): Promise<ViewFilterGroupEntity[]> {
return this.viewFilterGroupRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: [
'workspace',
'view',
'viewFilters',
'parentViewFilterGroup',
'childViewFilterGroups',
],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewFilterGroupEntity[]> {
return this.viewFilterGroupRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: [
'workspace',
'view',
'viewFilters',
'parentViewFilterGroup',
'childViewFilterGroups',
],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewFilterGroupEntity | null> {
const viewFilterGroup = await this.viewFilterGroupRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: [
'workspace',
'view',
'viewFilters',
'parentViewFilterGroup',
'childViewFilterGroups',
],
});
return viewFilterGroup || null;
}
async create(
viewFilterGroupData: Partial<ViewFilterGroupEntity>,
): Promise<ViewFilterGroupEntity> {
if (!isDefined(viewFilterGroupData.workspaceId)) {
throw new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFilterGroupExceptionCode.INVALID_VIEW_FILTER_GROUP_DATA,
{
userFriendlyMessage:
generateViewFilterGroupUserFriendlyExceptionMessage(
ViewFilterGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewFilterGroupData.viewId)) {
throw new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFilterGroupExceptionCode.INVALID_VIEW_FILTER_GROUP_DATA,
{
userFriendlyMessage:
generateViewFilterGroupUserFriendlyExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
const viewFilterGroup =
this.viewFilterGroupRepository.create(viewFilterGroupData);
await this.flushGraphQLCache(viewFilterGroupData.workspaceId);
const savedViewFilterGroup =
await this.viewFilterGroupRepository.save(viewFilterGroup);
await this.flushGraphQLCache(viewFilterGroupData.workspaceId);
return savedViewFilterGroup;
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewFilterGroupEntity>,
): Promise<ViewFilterGroupEntity> {
const existingViewFilterGroup = await this.findById(id, workspaceId);
if (!isDefined(existingViewFilterGroup)) {
throw new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
id,
),
ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND,
);
}
const updatedViewFilterGroup = await this.viewFilterGroupRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewFilterGroup, ...updatedViewFilterGroup };
}
async delete(
id: string,
workspaceId: string,
): Promise<ViewFilterGroupEntity> {
const viewFilterGroup = await this.findById(id, workspaceId);
if (!isDefined(viewFilterGroup)) {
throw new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
id,
),
ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND,
);
}
await this.viewFilterGroupRepository.softDelete(id);
return viewFilterGroup;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const viewFilterGroup = await this.findById(id, workspaceId);
if (!isDefined(viewFilterGroup)) {
throw new ViewFilterGroupException(
generateViewFilterGroupExceptionMessage(
ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND,
id,
),
ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND,
);
}
await this.viewFilterGroupRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return true;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ViewFilterGroupController } from 'src/engine/metadata-modules/view-filter-group/controllers/view-filter-group.controller';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterGroupResolver } from 'src/engine/metadata-modules/view-filter-group/resolvers/view-filter-group.resolver';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([ViewFilterGroupEntity]),
WorkspaceCacheStorageModule,
],
controllers: [ViewFilterGroupController],
providers: [ViewFilterGroupService, ViewFilterGroupResolver],
exports: [ViewFilterGroupService],
})
export class ViewFilterGroupModule {}
@@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input';
import { UpdateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/update-view-filter.input';
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
import {
generateViewFilterExceptionMessage,
generateViewFilterUserFriendlyExceptionMessage,
ViewFilterException,
ViewFilterExceptionCode,
ViewFilterExceptionMessageKey,
} from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { ViewFilterRestApiExceptionFilter } from 'src/engine/metadata-modules/view-filter/filters/view-filter-rest-api-exception.filter';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
@Controller('rest/metadata/viewFilters')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewFilterRestApiExceptionFilter)
export class ViewFilterController {
constructor(private readonly viewFilterService: ViewFilterService) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('viewId') viewId?: string,
): Promise<ViewFilterDTO[]> {
if (viewId) {
return this.viewFilterService.findByViewId(workspace.id, viewId);
}
return this.viewFilterService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterDTO> {
const viewFilter = await this.viewFilterService.findById(id, workspace.id);
if (!isDefined(viewFilter)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
id,
),
ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
),
},
);
}
return viewFilter;
}
@Post()
async create(
@Body() input: CreateViewFilterInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterDTO> {
return this.viewFilterService.create({
...input,
workspaceId: workspace.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdateViewFilterInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterDTO> {
const updatedViewFilter = await this.viewFilterService.update(
id,
workspace.id,
input,
);
return updatedViewFilter;
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<{ success: boolean }> {
const deletedViewFilter = await this.viewFilterService.delete(
id,
workspace.id,
);
return { success: isDefined(deletedViewFilter) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}
@@ -0,0 +1,34 @@
import { Field, InputType } from '@nestjs/graphql';
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 { ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
@InputType()
export class CreateViewFilterInput {
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
operand?: ViewFilterOperand;
@Field(() => GraphQLJSON, { nullable: false })
value: ViewFilterValue;
@Field(() => UUIDScalarType, { nullable: true })
viewFilterGroupId?: string;
@Field({ nullable: true })
positionInViewFilterGroup?: number;
@Field({ nullable: true })
subFieldName?: string;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
}
@@ -0,0 +1,6 @@
import { InputType, PartialType } from '@nestjs/graphql';
import { CreateViewFilterInput } from './create-view-filter.input';
@InputType()
export class UpdateViewFilterInput extends PartialType(CreateViewFilterInput) {}
@@ -0,0 +1,51 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
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 { ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
registerEnumType(ViewFilterOperand, {
name: 'ViewFilterOperand',
});
@ObjectType('CoreViewFilter')
export class ViewFilterDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: false, defaultValue: ViewFilterOperand.CONTAINS })
operand: ViewFilterOperand;
@Field(() => GraphQLJSON, { nullable: false })
value: ViewFilterValue;
@Field(() => UUIDScalarType, { nullable: true })
viewFilterGroupId?: string | null;
@Field(() => Number, { nullable: true })
positionInViewFilterGroup?: number | null;
@Field(() => String, { nullable: true })
subFieldName?: string | null;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,98 @@
import { ViewFilterOperand } from 'twenty-shared/types';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity({ name: 'viewFilter', schema: 'core' })
@Index('IDX_VIEW_FILTER_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FILTER_VIEW_ID', ['viewId'], {
where: '"deletedAt" IS NULL',
})
@Index('IDX_VIEW_FILTER_FIELD_METADATA_ID', ['fieldMetadataId'])
export class ViewFilterEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
fieldMetadataId: string;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@Column({
nullable: false,
type: 'enum',
enum: Object.values(ViewFilterOperand),
default: ViewFilterOperand.CONTAINS,
})
operand: ViewFilterOperand;
@Column({ nullable: false, type: 'jsonb' })
value: ViewFilterValue;
@Column({ nullable: true, type: 'uuid' })
viewFilterGroupId?: string | null;
@Column({ nullable: true, type: 'double precision' })
positionInViewFilterGroup: number | null;
@Column({ nullable: true, type: 'text', default: null })
subFieldName?: string | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@ManyToOne(() => ViewEntity, (view) => view.viewFilters, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
@ManyToOne(
() => ViewFilterGroupEntity,
(viewFilterGroup) => viewFilterGroup.viewFilters,
{
onDelete: 'CASCADE',
},
)
@JoinColumn({ name: 'viewFilterGroupId' })
viewFilterGroup: Relation<ViewFilterGroupEntity>;
}
@@ -0,0 +1,61 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export class ViewFilterException extends CustomException {
declare code: ViewFilterExceptionCode;
constructor(
message: string,
code: ViewFilterExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum ViewFilterExceptionCode {
VIEW_FILTER_NOT_FOUND = 'VIEW_FILTER_NOT_FOUND',
INVALID_VIEW_FILTER_DATA = 'INVALID_VIEW_FILTER_DATA',
}
export enum ViewFilterExceptionMessageKey {
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
VIEW_FILTER_NOT_FOUND = 'VIEW_FILTER_NOT_FOUND',
INVALID_VIEW_FILTER_DATA = 'INVALID_VIEW_FILTER_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
}
export const generateViewFilterExceptionMessage = (
key: ViewFilterExceptionMessageKey,
id?: string,
) => {
switch (key) {
case ViewFilterExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return 'WorkspaceId is required';
case ViewFilterExceptionMessageKey.VIEW_ID_REQUIRED:
return 'ViewId is required';
case ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND:
return `View filter${id ? ` (id: ${id})` : ''} not found`;
case ViewFilterExceptionMessageKey.INVALID_VIEW_FILTER_DATA:
return `Invalid view filter data${id ? ` for view filter id: ${id}` : ''}`;
case ViewFilterExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return 'FieldMetadataId is required';
default:
assertUnreachable(key);
}
};
export const generateViewFilterUserFriendlyExceptionMessage = (
key: ViewFilterExceptionMessageKey,
) => {
switch (key) {
case ViewFilterExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return t`WorkspaceId is required to create a view filter.`;
case ViewFilterExceptionMessageKey.VIEW_ID_REQUIRED:
return t`ViewId is required to create a view filter.`;
case ViewFilterExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return t`FieldMetadataId is required to create a view filter.`;
}
};
@@ -0,0 +1,48 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
ViewFilterException,
ViewFilterExceptionCode,
} from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(ViewFilterException)
export class ViewFilterRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ViewFilterException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
@@ -0,0 +1,87 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input';
import { UpdateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/update-view-filter.input';
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@Resolver(() => ViewFilterDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewFilterResolver {
constructor(private readonly viewFilterService: ViewFilterService) {}
@Query(() => [ViewFilterDTO])
async getCoreViewFilters(
@AuthWorkspace() workspace: Workspace,
@Args('viewId', { type: () => String, nullable: true })
viewId?: string,
): Promise<ViewFilterDTO[]> {
if (viewId) {
return this.viewFilterService.findByViewId(workspace.id, viewId);
}
return this.viewFilterService.findByWorkspaceId(workspace.id);
}
@Query(() => ViewFilterDTO, { nullable: true })
async getCoreViewFilter(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterDTO | null> {
return this.viewFilterService.findById(id, workspace.id);
}
@Mutation(() => ViewFilterDTO)
async createCoreViewFilter(
@Args('input') input: CreateViewFilterInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterDTO> {
return this.viewFilterService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ViewFilterDTO)
async updateCoreViewFilter(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewFilterInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewFilterDTO> {
return this.viewFilterService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
async deleteCoreViewFilter(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewFilter = await this.viewFilterService.delete(
id,
workspace.id,
);
return isDefined(deletedViewFilter);
}
@Mutation(() => Boolean)
async destroyCoreViewFilter(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewFilter = await this.viewFilterService.destroy(
id,
workspace.id,
);
return isDefined(deletedViewFilter);
}
}
@@ -0,0 +1,339 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ViewFilterOperand } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import {
ViewFilterException,
ViewFilterExceptionCode,
ViewFilterExceptionMessageKey,
generateViewFilterExceptionMessage,
generateViewFilterUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewFilterService', () => {
let viewFilterService: ViewFilterService;
let viewFilterRepository: Repository<ViewFilterEntity>;
const mockViewFilter = {
id: 'view-filter-id',
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
operand: ViewFilterOperand.CONTAINS,
value: 'test',
positionInViewFilterGroup: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewFilterEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewFilterService,
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
{
provide: getRepositoryToken(ViewFilterEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
viewFilterService = module.get<ViewFilterService>(ViewFilterService);
viewFilterRepository = module.get<Repository<ViewFilterEntity>>(
getRepositoryToken(ViewFilterEntity),
);
});
it('should be defined', () => {
expect(viewFilterService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view filters for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewFilters = [mockViewFilter];
jest
.spyOn(viewFilterRepository, 'find')
.mockResolvedValue(expectedViewFilters);
const result = await viewFilterService.findByWorkspaceId(workspaceId);
expect(viewFilterRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: ['workspace', 'view', 'viewFilterGroup'],
});
expect(result).toEqual(expectedViewFilters);
});
});
describe('findByViewId', () => {
it('should return view filters for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewFilters = [mockViewFilter];
jest
.spyOn(viewFilterRepository, 'find')
.mockResolvedValue(expectedViewFilters);
const result = await viewFilterService.findByViewId(workspaceId, viewId);
expect(viewFilterRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: ['workspace', 'view', 'viewFilterGroup'],
});
expect(result).toEqual(expectedViewFilters);
});
});
describe('findById', () => {
it('should return a view filter by id', async () => {
const id = 'view-filter-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFilterRepository, 'findOne')
.mockResolvedValue(mockViewFilter);
const result = await viewFilterService.findById(id, workspaceId);
expect(viewFilterRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view', 'viewFilterGroup'],
});
expect(result).toEqual(mockViewFilter);
});
it('should return null when view filter is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFilterRepository, 'findOne').mockResolvedValue(null);
const result = await viewFilterService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewFilterData = {
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
operand: ViewFilterOperand.CONTAINS,
value: 'test',
positionInViewFilterGroup: 0,
};
it('should create a view filter successfully', async () => {
jest
.spyOn(viewFilterRepository, 'create')
.mockReturnValue(mockViewFilter);
jest
.spyOn(viewFilterRepository, 'save')
.mockResolvedValue(mockViewFilter);
const result = await viewFilterService.create(validViewFilterData);
expect(viewFilterRepository.create).toHaveBeenCalledWith(
validViewFilterData,
);
expect(viewFilterRepository.save).toHaveBeenCalledWith(mockViewFilter);
expect(result).toEqual(mockViewFilter);
});
it('should throw exception when workspaceId is missing', async () => {
const invalidData = { ...validViewFilterData, workspaceId: undefined };
await expect(viewFilterService.create(invalidData)).rejects.toThrow(
new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewFilterData, viewId: undefined };
await expect(viewFilterService.create(invalidData)).rejects.toThrow(
new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when fieldMetadataId is missing', async () => {
const invalidData = {
...validViewFilterData,
fieldMetadataId: undefined,
};
await expect(viewFilterService.create(invalidData)).rejects.toThrow(
new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
),
);
});
});
describe('update', () => {
it('should update a view filter successfully', async () => {
const id = 'view-filter-id';
const workspaceId = 'workspace-id';
const updateData = { value: 'updated test' };
const updatedViewFilter = { ...mockViewFilter, ...updateData };
jest
.spyOn(viewFilterService, 'findById')
.mockResolvedValue(mockViewFilter);
jest
.spyOn(viewFilterRepository, 'save')
.mockResolvedValue(updatedViewFilter);
const result = await viewFilterService.update(
id,
workspaceId,
updateData,
);
expect(viewFilterService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFilterRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({ ...mockViewFilter, ...updatedViewFilter });
});
it('should throw exception when view filter is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { value: 'updated test' };
jest.spyOn(viewFilterService, 'findById').mockResolvedValue(null);
await expect(
viewFilterService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
id,
),
ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a view filter successfully', async () => {
const id = 'view-filter-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFilterService, 'findById')
.mockResolvedValue(mockViewFilter);
jest
.spyOn(viewFilterRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await viewFilterService.delete(id, workspaceId);
expect(viewFilterService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFilterRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewFilter);
});
it('should throw exception when view filter is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewFilterService, 'findById').mockResolvedValue(null);
await expect(viewFilterService.delete(id, workspaceId)).rejects.toThrow(
new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
id,
),
ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view filter successfully', async () => {
const id = 'view-filter-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewFilterService, 'findById')
.mockResolvedValue(mockViewFilter);
jest.spyOn(viewFilterRepository, 'delete').mockResolvedValue({} as any);
const result = await viewFilterService.destroy(id, workspaceId);
expect(viewFilterService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewFilterRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(true);
});
});
});
@@ -0,0 +1,197 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import {
ViewFilterException,
ViewFilterExceptionCode,
ViewFilterExceptionMessageKey,
generateViewFilterExceptionMessage,
generateViewFilterUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewFilterService {
constructor(
@InjectRepository(ViewFilterEntity)
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<ViewFilterEntity[]> {
return this.viewFilterRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: ['workspace', 'view', 'viewFilterGroup'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewFilterEntity[]> {
return this.viewFilterRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { positionInViewFilterGroup: 'ASC' },
relations: ['workspace', 'view', 'viewFilterGroup'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewFilterEntity | null> {
const viewFilter = await this.viewFilterRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view', 'viewFilterGroup'],
});
return viewFilter || null;
}
async create(
viewFilterData: Partial<ViewFilterEntity>,
): Promise<ViewFilterEntity> {
if (!isDefined(viewFilterData.workspaceId)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewFilterData.viewId)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewFilterData.fieldMetadataId)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA,
{
userFriendlyMessage: generateViewFilterUserFriendlyExceptionMessage(
ViewFilterExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
);
}
const viewFilter = this.viewFilterRepository.create(viewFilterData);
await this.flushGraphQLCache(viewFilterData.workspaceId);
const savedViewFilter = await this.viewFilterRepository.save(viewFilter);
await this.flushGraphQLCache(viewFilterData.workspaceId);
return savedViewFilter;
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewFilterEntity>,
): Promise<ViewFilterEntity> {
const existingViewFilter = await this.findById(id, workspaceId);
if (!isDefined(existingViewFilter)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
id,
),
ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND,
);
}
const updatedViewFilter = await this.viewFilterRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewFilter, ...updatedViewFilter };
}
async delete(id: string, workspaceId: string): Promise<ViewFilterEntity> {
const viewFilter = await this.findById(id, workspaceId);
if (!isDefined(viewFilter)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
id,
),
ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND,
);
}
await this.viewFilterRepository.softDelete(id);
await this.flushGraphQLCache(workspaceId);
return viewFilter;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const viewFilter = await this.findById(id, workspaceId);
if (!isDefined(viewFilter)) {
throw new ViewFilterException(
generateViewFilterExceptionMessage(
ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND,
id,
),
ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND,
);
}
await this.viewFilterRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return true;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}
@@ -0,0 +1,14 @@
export type RelationFilterValue = {
isCurrentWorkspaceMemberSelected?: boolean;
selectedRecordIds: string[];
};
export type ViewFilterValue =
| string
| string[]
| boolean
| number
| RelationFilterValue
| Record<string, unknown>
| null
| undefined;
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ViewFilterController } from 'src/engine/metadata-modules/view-filter/controllers/view-filter.controller';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewFilterResolver } from 'src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([ViewFilterEntity]),
WorkspaceCacheStorageModule,
],
controllers: [ViewFilterController],
providers: [ViewFilterService, ViewFilterResolver],
exports: [ViewFilterService],
})
export class ViewFilterModule {}
@@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { type ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import {
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { ViewGroupRestApiExceptionFilter } from 'src/engine/metadata-modules/view-group/filters/view-group-rest-api-exception.filter';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
@Controller('rest/metadata/viewGroups')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewGroupRestApiExceptionFilter)
export class ViewGroupController {
constructor(private readonly viewGroupService: ViewGroupService) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('viewId') viewId?: string,
): Promise<ViewGroupDTO[]> {
if (viewId) {
return this.viewGroupService.findByViewId(workspace.id, viewId);
}
return this.viewGroupService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
const viewGroup = await this.viewGroupService.findById(id, workspace.id);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
),
},
);
}
return viewGroup;
}
@Post()
async create(
@Body() input: CreateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
return this.viewGroupService.create({
...input,
workspaceId: workspace.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
const updatedViewGroup = await this.viewGroupService.update(
id,
workspace.id,
input,
);
return updatedViewGroup;
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<{ success: boolean }> {
const deletedViewGroup = await this.viewGroupService.delete(
id,
workspace.id,
);
return { success: isDefined(deletedViewGroup) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateViewGroupInput {
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: true, defaultValue: true })
isVisible?: boolean;
@Field({ nullable: false })
fieldValue: string;
@Field({ nullable: true, defaultValue: 0 })
position?: number;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
}
@@ -0,0 +1,6 @@
import { InputType, PartialType } from '@nestjs/graphql';
import { CreateViewGroupInput } from './create-view-group.input';
@InputType()
export class UpdateViewGroupInput extends PartialType(CreateViewGroupInput) {}
@@ -0,0 +1,38 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('CoreViewGroup')
export class ViewGroupDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: false, defaultValue: true })
isVisible: boolean;
@Field({ nullable: false })
fieldValue: string;
@Field({ nullable: false, defaultValue: 0 })
position: number;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,73 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity({ name: 'viewGroup', schema: 'core' })
@Index('IDX_VIEW_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_GROUP_VIEW_ID', ['viewId'], {
where: '"deletedAt" IS NULL',
})
export class ViewGroupEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
fieldMetadataId: string;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@Column({ nullable: false, default: true })
isVisible: boolean;
@Column({ nullable: false, type: 'text' })
fieldValue: string;
@Column({ nullable: false, type: 'double precision', default: 0 })
position: number;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@ManyToOne(() => ViewEntity, (view) => view.viewGroups, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
}
@@ -0,0 +1,61 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export class ViewGroupException extends CustomException {
declare code: ViewGroupExceptionCode;
constructor(
message: string,
code: ViewGroupExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum ViewGroupExceptionCode {
VIEW_GROUP_NOT_FOUND = 'VIEW_GROUP_NOT_FOUND',
INVALID_VIEW_GROUP_DATA = 'INVALID_VIEW_GROUP_DATA',
}
export enum ViewGroupExceptionMessageKey {
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
VIEW_GROUP_NOT_FOUND = 'VIEW_GROUP_NOT_FOUND',
INVALID_VIEW_GROUP_DATA = 'INVALID_VIEW_GROUP_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
}
export const generateViewGroupExceptionMessage = (
key: ViewGroupExceptionMessageKey,
id?: string,
) => {
switch (key) {
case ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return 'WorkspaceId is required';
case ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED:
return 'ViewId is required';
case ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND:
return `View group${id ? ` (id: ${id})` : ''} not found`;
case ViewGroupExceptionMessageKey.INVALID_VIEW_GROUP_DATA:
return `Invalid view group data${id ? ` for view group id: ${id}` : ''}`;
case ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return 'FieldMetadataId is required';
default:
assertUnreachable(key);
}
};
export const generateViewGroupUserFriendlyExceptionMessage = (
key: ViewGroupExceptionMessageKey,
) => {
switch (key) {
case ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return t`WorkspaceId is required to create a view group.`;
case ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED:
return t`ViewId is required to create a view group.`;
case ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return t`FieldMetadataId is required to create a view group.`;
}
};
@@ -0,0 +1,48 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
ViewGroupException,
ViewGroupExceptionCode,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(ViewGroupException)
export class ViewGroupRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ViewGroupException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
@@ -0,0 +1,87 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@Resolver(() => ViewGroupDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewGroupResolver {
constructor(private readonly viewGroupService: ViewGroupService) {}
@Query(() => [ViewGroupDTO])
async getCoreViewGroups(
@AuthWorkspace() workspace: Workspace,
@Args('viewId', { type: () => String, nullable: true })
viewId?: string,
): Promise<ViewGroupDTO[]> {
if (viewId) {
return this.viewGroupService.findByViewId(workspace.id, viewId);
}
return this.viewGroupService.findByWorkspaceId(workspace.id);
}
@Query(() => ViewGroupDTO, { nullable: true })
async getCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO | null> {
return this.viewGroupService.findById(id, workspace.id);
}
@Mutation(() => ViewGroupDTO)
async createCoreViewGroup(
@Args('input') input: CreateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
return this.viewGroupService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ViewGroupDTO)
async updateCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
return this.viewGroupService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
async deleteCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewGroup = await this.viewGroupService.delete(
id,
workspace.id,
);
return isDefined(deletedViewGroup);
}
@Mutation(() => Boolean)
async destroyCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewGroup = await this.viewGroupService.destroy(
id,
workspace.id,
);
return isDefined(deletedViewGroup);
}
}
@@ -0,0 +1,321 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import {
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewGroupService', () => {
let viewGroupService: ViewGroupService;
let viewGroupRepository: Repository<ViewGroupEntity>;
const mockViewGroup = {
id: 'view-group-id',
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
fieldValue: 'group-value',
isVisible: true,
position: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewGroupEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewGroupService,
{
provide: getRepositoryToken(ViewGroupEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
],
}).compile();
viewGroupService = module.get<ViewGroupService>(ViewGroupService);
viewGroupRepository = module.get<Repository<ViewGroupEntity>>(
getRepositoryToken(ViewGroupEntity),
);
});
it('should be defined', () => {
expect(viewGroupService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view groups for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewGroups = [mockViewGroup];
jest
.spyOn(viewGroupRepository, 'find')
.mockResolvedValue(expectedViewGroups);
const result = await viewGroupService.findByWorkspaceId(workspaceId);
expect(viewGroupRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewGroups);
});
});
describe('findByViewId', () => {
it('should return view groups for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewGroups = [mockViewGroup];
jest
.spyOn(viewGroupRepository, 'find')
.mockResolvedValue(expectedViewGroups);
const result = await viewGroupService.findByViewId(workspaceId, viewId);
expect(viewGroupRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewGroups);
});
});
describe('findById', () => {
it('should return a view group by id', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewGroupRepository, 'findOne')
.mockResolvedValue(mockViewGroup);
const result = await viewGroupService.findById(id, workspaceId);
expect(viewGroupRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(mockViewGroup);
});
it('should return null when view group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupRepository, 'findOne').mockResolvedValue(null);
const result = await viewGroupService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewGroupData = {
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
fieldValue: 'group-value',
isVisible: true,
position: 0,
};
it('should create a view group successfully', async () => {
jest.spyOn(viewGroupRepository, 'create').mockReturnValue(mockViewGroup);
jest.spyOn(viewGroupRepository, 'save').mockResolvedValue(mockViewGroup);
const result = await viewGroupService.create(validViewGroupData);
expect(viewGroupRepository.create).toHaveBeenCalledWith(
validViewGroupData,
);
expect(viewGroupRepository.save).toHaveBeenCalledWith(mockViewGroup);
expect(result).toEqual(mockViewGroup);
});
it('should throw exception when workspaceId is missing', async () => {
const invalidData = { ...validViewGroupData, workspaceId: undefined };
await expect(viewGroupService.create(invalidData)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewGroupData, viewId: undefined };
await expect(viewGroupService.create(invalidData)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when fieldMetadataId is missing', async () => {
const invalidData = { ...validViewGroupData, fieldMetadataId: undefined };
await expect(viewGroupService.create(invalidData)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
),
);
});
});
describe('update', () => {
it('should update a view group successfully', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
const updateData = { isVisible: false };
const updatedViewGroup = { ...mockViewGroup, ...updateData };
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(mockViewGroup);
jest
.spyOn(viewGroupRepository, 'save')
.mockResolvedValue(updatedViewGroup);
const result = await viewGroupService.update(id, workspaceId, updateData);
expect(viewGroupService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewGroupRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({ ...mockViewGroup, ...updatedViewGroup });
});
it('should throw exception when view group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { isVisible: false };
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(null);
await expect(
viewGroupService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a view group successfully', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(mockViewGroup);
jest
.spyOn(viewGroupRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await viewGroupService.delete(id, workspaceId);
expect(viewGroupService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewGroupRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewGroup);
});
it('should throw exception when view group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(null);
await expect(viewGroupService.delete(id, workspaceId)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view group successfully', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(mockViewGroup);
jest.spyOn(viewGroupRepository, 'delete').mockResolvedValue({} as any);
const result = await viewGroupService.destroy(id, workspaceId);
expect(viewGroupService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewGroupRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(true);
});
});
});
@@ -0,0 +1,197 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import {
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewGroupService {
constructor(
@InjectRepository(ViewGroupEntity)
private readonly viewGroupRepository: Repository<ViewGroupEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<ViewGroupEntity[]> {
return this.viewGroupRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewGroupEntity[]> {
return this.viewGroupRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewGroupEntity | null> {
const viewGroup = await this.viewGroupRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
return viewGroup || null;
}
async create(
viewGroupData: Partial<ViewGroupEntity>,
): Promise<ViewGroupEntity> {
if (!isDefined(viewGroupData.workspaceId)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewGroupData.viewId)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewGroupData.fieldMetadataId)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
);
}
const viewGroup = this.viewGroupRepository.create(viewGroupData);
await this.flushGraphQLCache(viewGroupData.workspaceId);
const savedViewGroup = await this.viewGroupRepository.save(viewGroup);
await this.flushGraphQLCache(viewGroupData.workspaceId);
return savedViewGroup;
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewGroupEntity>,
): Promise<ViewGroupEntity> {
const existingViewGroup = await this.findById(id, workspaceId);
if (!isDefined(existingViewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
);
}
const updatedViewGroup = await this.viewGroupRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewGroup, ...updatedViewGroup };
}
async delete(id: string, workspaceId: string): Promise<ViewGroupEntity> {
const viewGroup = await this.findById(id, workspaceId);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
);
}
await this.viewGroupRepository.softDelete(id);
await this.flushGraphQLCache(workspaceId);
return viewGroup;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const viewGroup = await this.findById(id, workspaceId);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
);
}
await this.viewGroupRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return true;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ViewGroupController } from 'src/engine/metadata-modules/view-group/controllers/view-group.controller';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewGroupResolver } from 'src/engine/metadata-modules/view-group/resolvers/view-group.resolver';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([ViewGroupEntity]),
WorkspaceCacheStorageModule,
],
controllers: [ViewGroupController],
providers: [ViewGroupService, ViewGroupResolver],
exports: [ViewGroupService],
})
export class ViewGroupModule {}
@@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/create-view-sort.input';
import { UpdateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/update-view-sort.input';
import { type ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import {
ViewSortException,
ViewSortExceptionCode,
ViewSortExceptionMessageKey,
generateViewSortExceptionMessage,
generateViewSortUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-sort/exceptions/view-sort.exception';
import { ViewSortRestApiExceptionFilter } from 'src/engine/metadata-modules/view-sort/filters/view-sort-rest-api-exception.filter';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
@Controller('rest/metadata/viewSorts')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewSortRestApiExceptionFilter)
export class ViewSortController {
constructor(private readonly viewSortService: ViewSortService) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('viewId') viewId?: string,
): Promise<ViewSortDTO[]> {
if (viewId) {
return this.viewSortService.findByViewId(workspace.id, viewId);
}
return this.viewSortService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewSortDTO> {
const viewSort = await this.viewSortService.findById(id, workspace.id);
if (!isDefined(viewSort)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
id,
),
ViewSortExceptionCode.VIEW_SORT_NOT_FOUND,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
),
},
);
}
return viewSort;
}
@Post()
async create(
@Body() input: CreateViewSortInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewSortDTO> {
return this.viewSortService.create({
...input,
workspaceId: workspace.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdateViewSortInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewSortDTO> {
const updatedViewSort = await this.viewSortService.update(
id,
workspace.id,
input,
);
return updatedViewSort;
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<{ success: boolean }> {
const deletedViewSort = await this.viewSortService.delete(id, workspace.id);
return { success: isDefined(deletedViewSort) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}
@@ -0,0 +1,22 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
@InputType()
export class CreateViewSortInput {
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field(() => ViewSortDirection, {
nullable: true,
defaultValue: ViewSortDirection.ASC,
})
direction?: ViewSortDirection;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
}
@@ -0,0 +1,6 @@
import { InputType, PartialType } from '@nestjs/graphql';
import { CreateViewSortInput } from './create-view-sort.input';
@InputType()
export class UpdateViewSortInput extends PartialType(CreateViewSortInput) {}
@@ -0,0 +1,38 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
registerEnumType(ViewSortDirection, { name: 'ViewSortDirection' });
@ObjectType('CoreViewSort')
export class ViewSortDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field(() => ViewSortDirection, {
nullable: false,
defaultValue: ViewSortDirection.ASC,
})
direction: ViewSortDirection;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,81 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity({ name: 'viewSort', schema: 'core' })
@Index('IDX_VIEW_SORT_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_SORT_VIEW_ID', ['viewId'], {
where: '"deletedAt" IS NULL',
})
@Index(
'IDX_VIEW_SORT_FIELD_METADATA_ID_VIEW_ID_UNIQUE',
['fieldMetadataId', 'viewId'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
export class ViewSortEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
fieldMetadataId: string;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@Column({
nullable: false,
type: 'enum',
enum: Object.values(ViewSortDirection),
default: ViewSortDirection.ASC,
})
direction: ViewSortDirection;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@ManyToOne(() => ViewEntity, (view) => view.viewSorts, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
}
@@ -0,0 +1,4 @@
export enum ViewSortDirection {
ASC = 'ASC',
DESC = 'DESC',
}
@@ -0,0 +1,74 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export class ViewSortException extends CustomException {
declare code: ViewSortExceptionCode;
constructor(
message: string,
code: ViewSortExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum ViewSortExceptionCode {
VIEW_SORT_NOT_FOUND = 'VIEW_SORT_NOT_FOUND',
INVALID_VIEW_SORT_DATA = 'INVALID_VIEW_SORT_DATA',
}
export enum ViewSortExceptionMessageKey {
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
VIEW_SORT_NOT_FOUND = 'VIEW_SORT_NOT_FOUND',
INVALID_VIEW_SORT_DATA = 'INVALID_VIEW_SORT_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
}
export const generateViewSortExceptionMessage = (
key: ViewSortExceptionMessageKey,
id?: string,
) => {
let message = '';
switch (key) {
case ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED:
message = t`WorkspaceId is required`;
break;
case ViewSortExceptionMessageKey.VIEW_ID_REQUIRED:
message = t`ViewId is required`;
break;
case ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND:
message = id
? t`View sort (id: ${id}) not found`
: t`View sort not found`;
break;
case ViewSortExceptionMessageKey.INVALID_VIEW_SORT_DATA:
message = id
? t`Invalid view sort data for view sort id: ${id}`
: t`Invalid view sort data`;
break;
case ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
message = t`FieldMetadataId is required`;
break;
default:
assertUnreachable(key);
}
return message;
};
export const generateViewSortUserFriendlyExceptionMessage = (
key: ViewSortExceptionMessageKey,
) => {
switch (key) {
case ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return t`WorkspaceId is required to create a view sort.`;
case ViewSortExceptionMessageKey.VIEW_ID_REQUIRED:
return t`ViewId is required to create a view sort.`;
case ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return t`FieldMetadataId is required to create a view sort.`;
}
};
@@ -0,0 +1,48 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
ViewSortException,
ViewSortExceptionCode,
} from 'src/engine/metadata-modules/view-sort/exceptions/view-sort.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(ViewSortException)
export class ViewSortRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ViewSortException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ViewSortExceptionCode.VIEW_SORT_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ViewSortExceptionCode.INVALID_VIEW_SORT_DATA:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
@@ -0,0 +1,84 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/create-view-sort.input';
import { UpdateViewSortInput } from 'src/engine/metadata-modules/view-sort/dtos/inputs/update-view-sort.input';
import { ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@Resolver(() => ViewSortDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewSortResolver {
constructor(private readonly viewSortService: ViewSortService) {}
@Query(() => [ViewSortDTO])
async getCoreViewSorts(
@AuthWorkspace() workspace: Workspace,
@Args('viewId', { type: () => String, nullable: true })
viewId?: string,
): Promise<ViewSortDTO[]> {
if (viewId) {
return this.viewSortService.findByViewId(workspace.id, viewId);
}
return this.viewSortService.findByWorkspaceId(workspace.id);
}
@Query(() => ViewSortDTO, { nullable: true })
async getCoreViewSort(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewSortDTO | null> {
return this.viewSortService.findById(id, workspace.id);
}
@Mutation(() => ViewSortDTO)
async createCoreViewSort(
@Args('input') input: CreateViewSortInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewSortDTO> {
return this.viewSortService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ViewSortDTO)
async updateCoreViewSort(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewSortInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewSortDTO> {
return this.viewSortService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
async deleteCoreViewSort(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewSort = await this.viewSortService.delete(id, workspace.id);
return isDefined(deletedViewSort);
}
@Mutation(() => Boolean)
async destroyCoreViewSort(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewSort = await this.viewSortService.destroy(
id,
workspace.id,
);
return isDefined(deletedViewSort);
}
}
@@ -0,0 +1,308 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import {
ViewSortException,
ViewSortExceptionCode,
ViewSortExceptionMessageKey,
generateViewSortExceptionMessage,
generateViewSortUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-sort/exceptions/view-sort.exception';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewSortService', () => {
let viewSortService: ViewSortService;
let viewSortRepository: Repository<ViewSortEntity>;
const mockViewSort = {
id: 'view-sort-id',
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
direction: ViewSortDirection.ASC,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewSortEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewSortService,
{
provide: getRepositoryToken(ViewSortEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
],
}).compile();
viewSortService = module.get<ViewSortService>(ViewSortService);
viewSortRepository = module.get<Repository<ViewSortEntity>>(
getRepositoryToken(ViewSortEntity),
);
});
it('should be defined', () => {
expect(viewSortService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view sorts for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewSorts = [mockViewSort];
jest
.spyOn(viewSortRepository, 'find')
.mockResolvedValue(expectedViewSorts);
const result = await viewSortService.findByWorkspaceId(workspaceId);
expect(viewSortRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewSorts);
});
});
describe('findByViewId', () => {
it('should return view sorts for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewSorts = [mockViewSort];
jest
.spyOn(viewSortRepository, 'find')
.mockResolvedValue(expectedViewSorts);
const result = await viewSortService.findByViewId(workspaceId, viewId);
expect(viewSortRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewSorts);
});
});
describe('findById', () => {
it('should return a view sort by id', async () => {
const id = 'view-sort-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewSortRepository, 'findOne').mockResolvedValue(mockViewSort);
const result = await viewSortService.findById(id, workspaceId);
expect(viewSortRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(mockViewSort);
});
it('should return null when view sort is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewSortRepository, 'findOne').mockResolvedValue(null);
const result = await viewSortService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewSortData = {
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
direction: ViewSortDirection.ASC,
};
it('should create a view sort successfully', async () => {
jest.spyOn(viewSortRepository, 'create').mockReturnValue(mockViewSort);
jest.spyOn(viewSortRepository, 'save').mockResolvedValue(mockViewSort);
const result = await viewSortService.create(validViewSortData);
expect(viewSortRepository.create).toHaveBeenCalledWith(validViewSortData);
expect(viewSortRepository.save).toHaveBeenCalledWith(mockViewSort);
expect(result).toEqual(mockViewSort);
});
it('should throw exception when workspaceId is missing', async () => {
const invalidData = { ...validViewSortData, workspaceId: undefined };
await expect(viewSortService.create(invalidData)).rejects.toThrow(
new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewSortExceptionCode.INVALID_VIEW_SORT_DATA,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewSortData, viewId: undefined };
await expect(viewSortService.create(invalidData)).rejects.toThrow(
new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewSortExceptionCode.INVALID_VIEW_SORT_DATA,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when fieldMetadataId is missing', async () => {
const invalidData = { ...validViewSortData, fieldMetadataId: undefined };
await expect(viewSortService.create(invalidData)).rejects.toThrow(
new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewSortExceptionCode.INVALID_VIEW_SORT_DATA,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
),
);
});
});
describe('update', () => {
it('should update a view sort successfully', async () => {
const id = 'view-sort-id';
const workspaceId = 'workspace-id';
const updateData = { direction: ViewSortDirection.DESC };
const updatedViewSort = { ...mockViewSort, ...updateData };
jest.spyOn(viewSortService, 'findById').mockResolvedValue(mockViewSort);
jest.spyOn(viewSortRepository, 'save').mockResolvedValue(updatedViewSort);
const result = await viewSortService.update(id, workspaceId, updateData);
expect(viewSortService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewSortRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({ ...mockViewSort, ...updatedViewSort });
});
it('should throw exception when view sort is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { direction: ViewSortDirection.DESC };
jest.spyOn(viewSortService, 'findById').mockResolvedValue(null);
await expect(
viewSortService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
id,
),
ViewSortExceptionCode.VIEW_SORT_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a view sort successfully', async () => {
const id = 'view-sort-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewSortService, 'findById').mockResolvedValue(mockViewSort);
jest.spyOn(viewSortRepository, 'softDelete').mockResolvedValue({} as any);
const result = await viewSortService.delete(id, workspaceId);
expect(viewSortService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewSortRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewSort);
});
it('should throw exception when view sort is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewSortService, 'findById').mockResolvedValue(null);
await expect(viewSortService.delete(id, workspaceId)).rejects.toThrow(
new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
id,
),
ViewSortExceptionCode.VIEW_SORT_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view sort successfully', async () => {
const id = 'view-sort-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewSortService, 'findById').mockResolvedValue(mockViewSort);
jest.spyOn(viewSortRepository, 'delete').mockResolvedValue({} as any);
const result = await viewSortService.destroy(id, workspaceId);
expect(viewSortService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewSortRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(true);
});
});
});
@@ -0,0 +1,191 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import {
ViewSortException,
ViewSortExceptionCode,
ViewSortExceptionMessageKey,
generateViewSortExceptionMessage,
generateViewSortUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-sort/exceptions/view-sort.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewSortService {
constructor(
@InjectRepository(ViewSortEntity)
private readonly viewSortRepository: Repository<ViewSortEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<ViewSortEntity[]> {
return this.viewSortRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewSortEntity[]> {
return this.viewSortRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewSortEntity | null> {
const viewSort = await this.viewSortRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
return viewSort || null;
}
async create(viewSortData: Partial<ViewSortEntity>): Promise<ViewSortEntity> {
if (!isDefined(viewSortData.workspaceId)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewSortExceptionCode.INVALID_VIEW_SORT_DATA,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewSortData.viewId)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewSortExceptionCode.INVALID_VIEW_SORT_DATA,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewSortData.fieldMetadataId)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewSortExceptionCode.INVALID_VIEW_SORT_DATA,
{
userFriendlyMessage: generateViewSortUserFriendlyExceptionMessage(
ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
);
}
const viewSort = this.viewSortRepository.create(viewSortData);
const savedViewSort = await this.viewSortRepository.save(viewSort);
await this.flushGraphQLCache(viewSortData.workspaceId);
return savedViewSort;
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewSortEntity>,
): Promise<ViewSortEntity> {
const existingViewSort = await this.findById(id, workspaceId);
if (!isDefined(existingViewSort)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
id,
),
ViewSortExceptionCode.VIEW_SORT_NOT_FOUND,
);
}
const updatedViewSort = await this.viewSortRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewSort, ...updatedViewSort };
}
async delete(id: string, workspaceId: string): Promise<ViewSortEntity> {
const viewSort = await this.findById(id, workspaceId);
if (!isDefined(viewSort)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
id,
),
ViewSortExceptionCode.VIEW_SORT_NOT_FOUND,
);
}
await this.viewSortRepository.softDelete(id);
await this.flushGraphQLCache(workspaceId);
return viewSort;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const viewSort = await this.findById(id, workspaceId);
if (!isDefined(viewSort)) {
throw new ViewSortException(
generateViewSortExceptionMessage(
ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND,
id,
),
ViewSortExceptionCode.VIEW_SORT_NOT_FOUND,
);
}
await this.viewSortRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return true;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}

Some files were not shown because too many files have changed in this diff Show More