From 593b064448151b07a5fdd0a1f95c54d2ca1f4713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 7 Aug 2025 18:45:04 +0200 Subject: [PATCH] Create resolvers and controllers for core views (#13624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created: - Services - Resolvers - Controllers - Tests for services - Integration tests for GraphQL and Rest Updated the Rest API playground Added new feature flag `IS_CORE_VIEW_ENABLED` Updated `viewFilter` `operand` and `view` `type` to be enums rather than strings and generated migration file. Closes https://github.com/twentyhq/core-team-issues/issues/1259 --------- Co-authored-by: Félix Malfait Co-authored-by: Charles Bochet --- .../src/generated-metadata/graphql.ts | 439 +++++++++++++++++ .../twenty-front/src/generated/graphql.ts | 439 +++++++++++++++++ .../migrate-views-to-core.command.ts | 27 +- .../1754578392938-updateCoreViewDataModel.ts | 197 ++++++++ .../engine/core-modules/core-engine.module.ts | 2 + .../enums/feature-flag-key.enum.ts | 1 + .../core-modules/open-api/open-api.service.ts | 24 + .../view/controllers/view-field.controller.ts | 90 ++++ .../view-filter-group.controller.ts | 92 ++++ .../controllers/view-filter.controller.ts | 90 ++++ .../view/controllers/view-group.controller.ts | 90 ++++ .../view/controllers/view-sort.controller.ts | 87 ++++ .../view/controllers/view.controller.ts | 87 ++++ .../dtos/inputs/create-view-field.input.ts | 25 + .../inputs/create-view-filter-group.input.ts | 22 + .../dtos/inputs/create-view-filter.input.ts | 30 ++ .../dtos/inputs/create-view-group.input.ts | 21 + .../dtos/inputs/create-view-sort.input.ts | 19 + .../view/dtos/inputs/create-view.input.ts | 45 ++ .../dtos/inputs/update-view-field.input.ts | 18 + .../inputs/update-view-filter-group.input.ts | 8 + .../dtos/inputs/update-view-filter.input.ts | 6 + .../dtos/inputs/update-view-group.input.ts | 6 + .../dtos/inputs/update-view-sort.input.ts | 6 + .../view/dtos/inputs/update-view.input.ts | 6 + .../core-modules/view/dtos/view-field.dto.ts | 44 ++ .../view/dtos/view-filter-group.dto.ts | 43 ++ .../core-modules/view/dtos/view-filter.dto.ts | 53 +++ .../core-modules/view/dtos/view-group.dto.ts | 38 ++ .../core-modules/view/dtos/view-sort.dto.ts | 38 ++ .../engine/core-modules/view/dtos/view.dto.ts | 65 +++ .../view/entities}/view-field.entity.ts | 4 +- .../entities}/view-filter-group.entity.ts | 32 +- .../view/entities}/view-filter.entity.ts | 26 +- .../view/entities}/view-group.entity.ts | 2 +- .../view/entities}/view-sort.entity.ts | 9 +- .../view/entities}/view.entity.ts | 30 +- .../view-filter-group-logical-operator.ts | 0 .../view/enums/view-filter-operand.ts | 18 + .../view/enums/view-open-record-in.ts | 0 .../view/enums/view-sort-direction.ts | 0 .../core-modules/view/enums/view-type.enum.ts | 4 + .../view/exceptions/view-field.exception.ts | 61 +++ .../exceptions/view-filter-group.exception.ts | 61 +++ .../view/exceptions/view-filter.exception.ts | 61 +++ .../view/exceptions/view-group.exception.ts | 61 +++ .../view/exceptions/view-sort.exception.ts | 70 +++ .../view/exceptions/view.exception.ts | 56 +++ .../view-field-rest-api-exception.filter.ts | 44 ++ ...-filter-group-rest-api-exception.filter.ts | 44 ++ .../view-filter-rest-api-exception.filter.ts | 44 ++ .../view-group-rest-api-exception.filter.ts | 44 ++ .../filters/view-rest-api-exception.filter.ts | 44 ++ .../view-sort-rest-api-exception.filter.ts | 44 ++ .../view/resolvers/view-field.resolver.ts | 70 +++ .../resolvers/view-filter-group.resolver.ts | 76 +++ .../view/resolvers/view-filter.resolver.ts | 74 +++ .../view/resolvers/view-group.resolver.ts | 74 +++ .../view/resolvers/view-sort.resolver.ts | 71 +++ .../view/resolvers/view.resolver.ts | 74 +++ .../services/tests/view-field.service.spec.ts | 297 ++++++++++++ .../tests/view-filter-group.service.spec.ts | 332 +++++++++++++ .../tests/view-filter.service.spec.ts | 313 ++++++++++++ .../services/tests/view-group.service.spec.ts | 297 ++++++++++++ .../services/tests/view-sort.service.spec.ts | 284 +++++++++++ .../view/services/tests/view.service.spec.ts | 298 ++++++++++++ .../view/services/view-field.service.ts | 152 ++++++ .../services/view-filter-group.service.ts | 164 +++++++ .../view/services/view-filter.service.ts | 152 ++++++ .../view/services/view-group.service.ts | 152 ++++++ .../view/services/view-sort.service.ts | 147 ++++++ .../view/services/view.service.ts | 159 +++++++ .../view/types/view-filter-value.type.ts | 0 ...view-graphql-api-exception-handler.util.ts | 128 +++++ .../view-graphql-api-exception.filter.ts | 31 ++ .../engine/core-modules/view/view.module.ts | 71 +++ .../workspace-entity-manager.spec.ts | 1 + .../core/utils/seed-feature-flags.util.ts | 5 + .../view/services/view-field-sync.service.ts | 2 +- .../view-filter-group-sync.service.ts | 4 +- .../view/services/view-filter-sync.service.ts | 12 +- .../view/services/view-group-sync.service.ts | 2 +- .../view/services/view-sort-sync.service.ts | 4 +- .../view/services/view-sync.service.ts | 10 +- ...iew-filter-operand-to-core-operand.util.ts | 30 ++ ...ew-filter-workspace-value-to-core-value.ts | 2 +- .../src/modules/view/view.module.ts | 13 +- .../constants/test-view-ids.constants.ts | 21 + .../constants/view-gql-fields.constants.ts | 70 +++ .../view-field-resolver.integration-spec.ts | 213 +++++++++ ...-filter-group-resolver.integration-spec.ts | 404 ++++++++++++++++ .../view-filter-resolver.integration-spec.ts | 225 +++++++++ .../view-group-resolver.integration-spec.ts | 218 +++++++++ .../view/view-resolver.integration-spec.ts | 261 ++++++++++ .../view-sort-resolver.integration-spec.ts | 194 ++++++++ ...reate-view-field-operation-factory.util.ts | 25 + ...iew-filter-group-operation-factory.util.ts | 25 + ...eate-view-filter-operation-factory.util.ts | 25 + ...reate-view-group-operation-factory.util.ts | 25 + .../create-view-operation-factory.util.ts | 25 + ...create-view-sort-operation-factory.util.ts | 25 + ...elete-view-field-operation-factory.util.ts | 16 + ...iew-filter-group-operation-factory.util.ts | 16 + ...lete-view-filter-operation-factory.util.ts | 16 + ...elete-view-group-operation-factory.util.ts | 16 + .../delete-view-operation-factory.util.ts | 12 + ...delete-view-sort-operation-factory.util.ts | 16 + ...find-view-fields-operation-factory.util.ts | 21 + ...iew-filter-group-operation-factory.util.ts | 21 + ...ew-filter-groups-operation-factory.util.ts | 19 + ...ind-view-filters-operation-factory.util.ts | 21 + ...find-view-groups-operation-factory.util.ts | 21 + .../utils/find-view-operation-factory.util.ts | 21 + .../find-view-sorts-operation-factory.util.ts | 21 + .../find-views-operation-factory.util.ts | 19 + .../utils/graphql-test-assertions.util.ts | 45 ++ ...pdate-view-field-operation-factory.util.ts | 24 + ...iew-filter-group-operation-factory.util.ts | 24 + ...date-view-filter-operation-factory.util.ts | 24 + ...pdate-view-group-operation-factory.util.ts | 24 + .../update-view-operation-factory.util.ts | 24 + ...update-view-sort-operation-factory.util.ts | 24 + .../graphql/utils/view-data-factory.util.ts | 121 +++++ .../graphql/utils/view-graphql.util.ts | 34 ++ ...ete-one-field-metadata.integration-spec.ts | 4 +- .../suites/view-field.integration-spec.ts | 271 +++++++++++ .../view-filter-group.integration-spec.ts | 445 ++++++++++++++++++ .../suites/view-filter.integration-spec.ts | 278 +++++++++++ .../suites/view-group.integration-spec.ts | 384 +++++++++++++++ .../rest/suites/view-sort.integration-spec.ts | 239 ++++++++++ .../rest/suites/view.integration-spec.ts | 263 +++++++++++ .../rest/utils/rest-test-assertions.util.ts | 34 ++ .../rest/utils/view-rest-api.util.ts | 242 ++++++++++ .../test/integration/utils/view-test.util.ts | 115 +++++ 134 files changed, 10987 insertions(+), 62 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1754578392938-updateCoreViewDataModel.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/controllers/view-field.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/controllers/view-filter-group.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/controllers/view-filter.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/controllers/view-group.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/controllers/view-sort.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-field.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter-group.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-group.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-sort.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-field.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter-group.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-group.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-sort.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/view-field.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/view-filter-group.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/view-filter.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/view-group.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/view-sort.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/dtos/view.dto.ts rename packages/twenty-server/src/engine/{metadata-modules/view => core-modules/view/entities}/view-field.entity.ts (93%) rename packages/twenty-server/src/engine/{metadata-modules/view => core-modules/view/entities}/view-filter-group.entity.ts (65%) rename packages/twenty-server/src/engine/{metadata-modules/view => core-modules/view/entities}/view-filter.entity.ts (69%) rename packages/twenty-server/src/engine/{metadata-modules/view => core-modules/view/entities}/view-group.entity.ts (95%) rename packages/twenty-server/src/engine/{metadata-modules/view => core-modules/view/entities}/view-sort.entity.ts (85%) rename packages/twenty-server/src/engine/{metadata-modules/view => core-modules/view/entities}/view.entity.ts (75%) rename packages/twenty-server/src/engine/{metadata-modules => core-modules}/view/enums/view-filter-group-logical-operator.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/view/enums/view-filter-operand.ts rename packages/twenty-server/src/engine/{metadata-modules => core-modules}/view/enums/view-open-record-in.ts (100%) rename packages/twenty-server/src/engine/{metadata-modules => core-modules}/view/enums/view-sort-direction.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/view/enums/view-type.enum.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/exceptions/view-field.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter-group.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/exceptions/view-group.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/exceptions/view-sort.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/exceptions/view.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/filters/view-field-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/filters/view-filter-group-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/filters/view-filter-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/filters/view-group-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/filters/view-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/filters/view-sort-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/resolvers/view-field.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter-group.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/resolvers/view-group.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/resolvers/view-sort.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter-group.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/tests/view-group.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/tests/view-sort.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/view-filter-group.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/view-filter.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/view-group.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/view-sort.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/services/view.service.ts rename packages/twenty-server/src/engine/{metadata-modules => core-modules}/view/types/view-filter-value.type.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception-handler.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/view/view.module.ts create mode 100644 packages/twenty-server/src/modules/view/utils/convert-view-filter-operand-to-core-operand.util.ts create mode 100644 packages/twenty-server/test/integration/constants/test-view-ids.constants.ts create mode 100644 packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-field-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-filter-group-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-group-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-sort-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-view-field-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-view-filter-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-view-filter-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-view-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-view-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-view-sort-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-view-field-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-view-filter-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-view-filter-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-view-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-view-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-view-sort-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-fields-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-filter-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-filter-groups-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-filters-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-groups-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-view-sorts-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-views-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/graphql-test-assertions.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-view-field-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-view-filter-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-view-filter-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-view-group-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-view-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-view-sort-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/view-data-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/view-graphql.util.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/view-field.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/view-filter-group.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/view-group.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/view-sort.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/view.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/utils/rest-test-assertions.util.ts create mode 100644 packages/twenty-server/test/integration/rest/utils/view-rest-api.util.ts create mode 100644 packages/twenty-server/test/integration/utils/view-test.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 8bd126a6db..7aef20315c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -18,6 +18,7 @@ export type Scalars = { DateTime: string; JSON: any; JSONObject: any; + RawJSONScalar: any; UUID: string; Upload: any; }; @@ -96,6 +97,21 @@ export type AgentIdInput = { id: Scalars['UUID']; }; +export enum AggregateOperations { + AVG = 'AVG', + COUNT = 'COUNT', + COUNT_EMPTY = 'COUNT_EMPTY', + COUNT_FALSE = 'COUNT_FALSE', + COUNT_NOT_EMPTY = 'COUNT_NOT_EMPTY', + COUNT_TRUE = 'COUNT_TRUE', + COUNT_UNIQUE_VALUES = 'COUNT_UNIQUE_VALUES', + MAX = 'MAX', + MIN = 'MIN', + PERCENTAGE_EMPTY = 'PERCENTAGE_EMPTY', + PERCENTAGE_NOT_EMPTY = 'PERCENTAGE_NOT_EMPTY', + SUM = 'SUM' +} + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -480,6 +496,96 @@ export type ConnectionParametersOutput = { username?: Maybe; }; +export type CoreView = { + __typename?: 'CoreView'; + anyFieldFilterValue?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + icon: Scalars['String']; + id: Scalars['UUID']; + isCompact: Scalars['Boolean']; + kanbanAggregateOperation?: Maybe; + kanbanAggregateOperationFieldMetadataId?: Maybe; + key?: Maybe; + name: Scalars['String']; + objectMetadataId: Scalars['UUID']; + openRecordIn: ViewOpenRecordIn; + position: Scalars['Float']; + type: ViewType; + updatedAt: Scalars['DateTime']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewField = { + __typename?: 'CoreViewField'; + aggregateOperation?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + isVisible: Scalars['Boolean']; + position: Scalars['Float']; + size: Scalars['Float']; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewFilter = { + __typename?: 'CoreViewFilter'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + operand: ViewFilterOperand; + positionInViewFilterGroup?: Maybe; + subFieldName?: Maybe; + updatedAt: Scalars['DateTime']; + value: Scalars['RawJSONScalar']; + viewFilterGroupId?: Maybe; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewFilterGroup = { + __typename?: 'CoreViewFilterGroup'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['UUID']; + logicalOperator: ViewFilterGroupLogicalOperator; + parentViewFilterGroupId?: Maybe; + positionInViewFilterGroup?: Maybe; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewGroup = { + __typename?: 'CoreViewGroup'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + fieldMetadataId: Scalars['UUID']; + fieldValue: Scalars['String']; + id: Scalars['UUID']; + isVisible: Scalars['Boolean']; + position: Scalars['Float']; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewSort = { + __typename?: 'CoreViewSort'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + direction: ViewSortDirection; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + export type CreateAgentChatThreadInput = { agentId: Scalars['UUID']; }; @@ -601,6 +707,60 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateViewFieldInput = { + aggregateOperation?: InputMaybe; + fieldMetadataId: Scalars['UUID']; + isVisible?: InputMaybe; + position?: InputMaybe; + size?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewFilterGroupInput = { + logicalOperator?: InputMaybe; + parentViewFilterGroupId?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewFilterInput = { + fieldMetadataId: Scalars['UUID']; + operand?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + subFieldName?: InputMaybe; + value: Scalars['RawJSONScalar']; + viewFilterGroupId?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewGroupInput = { + fieldMetadataId: Scalars['UUID']; + fieldValue: Scalars['String']; + isVisible?: InputMaybe; + position?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewInput = { + anyFieldFilterValue?: InputMaybe; + icon: Scalars['String']; + isCompact?: InputMaybe; + kanbanAggregateOperation?: InputMaybe; + kanbanAggregateOperationFieldMetadataId?: InputMaybe; + key?: InputMaybe; + name: Scalars['String']; + objectMetadataId: Scalars['UUID']; + openRecordIn?: InputMaybe; + position?: InputMaybe; + type?: InputMaybe; +}; + +export type CreateViewSortInput = { + direction?: InputMaybe; + fieldMetadataId: Scalars['UUID']; + viewId: Scalars['UUID']; +}; + export type CreateWebhookDto = { description?: InputMaybe; operations: Array; @@ -787,6 +947,7 @@ export enum FeatureFlagKey { IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', IS_API_KEY_ROLES_ENABLED = 'IS_API_KEY_ROLES_ENABLED', + IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', @@ -1168,6 +1329,12 @@ export type Mutation = { createAgentHandoff: Scalars['Boolean']; createApiKey: ApiKey; createApprovedAccessDomain: ApprovedAccessDomain; + createCoreView: CoreView; + createCoreViewField: CoreViewField; + createCoreViewFilter: CoreViewFilter; + createCoreViewFilterGroup: CoreViewFilterGroup; + createCoreViewGroup: CoreViewGroup; + createCoreViewSort: CoreViewSort; createDatabaseConfigVariable: Scalars['Boolean']; createDraftFromWorkflowVersion: WorkflowVersion; createFile: File; @@ -1186,6 +1353,12 @@ export type Mutation = { createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; + deleteCoreView: Scalars['Boolean']; + deleteCoreViewField: Scalars['Boolean']; + deleteCoreViewFilter: Scalars['Boolean']; + deleteCoreViewFilterGroup: Scalars['Boolean']; + deleteCoreViewGroup: Scalars['Boolean']; + deleteCoreViewSort: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteDatabaseConfigVariable: Scalars['Boolean']; deleteFile: File; @@ -1243,6 +1416,12 @@ export type Mutation = { trackAnalytics: Analytics; unsyncRemoteTable: RemoteTable; updateApiKey?: Maybe; + updateCoreView: CoreView; + updateCoreViewField: CoreViewField; + updateCoreViewFilter: CoreViewFilter; + updateCoreViewFilterGroup: CoreViewFilterGroup; + updateCoreViewGroup: CoreViewGroup; + updateCoreViewSort: CoreViewSort; updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; @@ -1334,6 +1513,36 @@ export type MutationCreateApprovedAccessDomainArgs = { }; +export type MutationCreateCoreViewArgs = { + input: CreateViewInput; +}; + + +export type MutationCreateCoreViewFieldArgs = { + input: CreateViewFieldInput; +}; + + +export type MutationCreateCoreViewFilterArgs = { + input: CreateViewFilterInput; +}; + + +export type MutationCreateCoreViewFilterGroupArgs = { + input: CreateViewFilterGroupInput; +}; + + +export type MutationCreateCoreViewGroupArgs = { + input: CreateViewGroupInput; +}; + + +export type MutationCreateCoreViewSortArgs = { + input: CreateViewSortInput; +}; + + export type MutationCreateDatabaseConfigVariableArgs = { key: Scalars['String']; value: Scalars['JSON']; @@ -1428,6 +1637,36 @@ export type MutationDeleteApprovedAccessDomainArgs = { }; +export type MutationDeleteCoreViewArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewFieldArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewFilterArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewFilterGroupArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewGroupArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewSortArgs = { + id: Scalars['String']; +}; + + export type MutationDeleteDatabaseConfigVariableArgs = { key: Scalars['String']; }; @@ -1693,6 +1932,42 @@ export type MutationUpdateApiKeyArgs = { }; +export type MutationUpdateCoreViewArgs = { + id: Scalars['String']; + input: UpdateViewInput; +}; + + +export type MutationUpdateCoreViewFieldArgs = { + id: Scalars['String']; + input: UpdateViewFieldInput; +}; + + +export type MutationUpdateCoreViewFilterArgs = { + id: Scalars['String']; + input: UpdateViewFilterInput; +}; + + +export type MutationUpdateCoreViewFilterGroupArgs = { + id: Scalars['String']; + input: UpdateViewFilterGroupInput; +}; + + +export type MutationUpdateCoreViewGroupArgs = { + id: Scalars['String']; + input: UpdateViewGroupInput; +}; + + +export type MutationUpdateCoreViewSortArgs = { + id: Scalars['String']; + input: UpdateViewSortInput; +}; + + export type MutationUpdateDatabaseConfigVariableArgs = { key: Scalars['String']; value: Scalars['JSON']; @@ -2097,6 +2372,18 @@ export type Query = { getAvailablePackages: Scalars['JSON']; getConfigVariablesGrouped: ConfigVariablesOutput; getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount; + getCoreView?: Maybe; + getCoreViewField?: Maybe; + getCoreViewFields: Array; + getCoreViewFilter?: Maybe; + getCoreViewFilterGroup?: Maybe; + getCoreViewFilterGroups: Array; + getCoreViewFilters: Array; + getCoreViewGroup?: Maybe; + getCoreViewGroups: Array; + getCoreViewSort?: Maybe; + getCoreViewSorts: Array; + getCoreViews: Array; getDatabaseConfigVariable: ConfigVariable; getIndicatorHealthStatus: AdminPanelHealthServiceData; getMeteredProductsUsage: Array; @@ -2237,6 +2524,66 @@ export type QueryGetConnectedImapSmtpCaldavAccountArgs = { }; +export type QueryGetCoreViewArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFieldArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFieldsArgs = { + viewId: Scalars['String']; +}; + + +export type QueryGetCoreViewFilterArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFilterGroupArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFilterGroupsArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewFiltersArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewGroupArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewGroupsArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewSortArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewSortsArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewsArgs = { + objectMetadataId?: InputMaybe; +}; + + export type QueryGetDatabaseConfigVariableArgs = { key: Scalars['String']; }; @@ -2891,6 +3238,58 @@ export type UpdateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type UpdateViewFieldInput = { + aggregateOperation?: InputMaybe; + isVisible?: InputMaybe; + position?: InputMaybe; + size?: InputMaybe; +}; + +export type UpdateViewFilterGroupInput = { + logicalOperator?: InputMaybe; + parentViewFilterGroupId?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + viewId?: InputMaybe; +}; + +export type UpdateViewFilterInput = { + fieldMetadataId?: InputMaybe; + operand?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + subFieldName?: InputMaybe; + value?: InputMaybe; + viewFilterGroupId?: InputMaybe; + viewId?: InputMaybe; +}; + +export type UpdateViewGroupInput = { + fieldMetadataId?: InputMaybe; + fieldValue?: InputMaybe; + isVisible?: InputMaybe; + position?: InputMaybe; + viewId?: InputMaybe; +}; + +export type UpdateViewInput = { + anyFieldFilterValue?: InputMaybe; + icon?: InputMaybe; + isCompact?: InputMaybe; + kanbanAggregateOperation?: InputMaybe; + kanbanAggregateOperationFieldMetadataId?: InputMaybe; + key?: InputMaybe; + name?: InputMaybe; + objectMetadataId?: InputMaybe; + openRecordIn?: InputMaybe; + position?: InputMaybe; + type?: InputMaybe; +}; + +export type UpdateViewSortInput = { + direction?: InputMaybe; + fieldMetadataId?: InputMaybe; + viewId?: InputMaybe; +}; + export type UpdateWebhookDto = { description?: InputMaybe; id: Scalars['UUID']; @@ -3056,6 +3455,46 @@ export type VersionInfo = { latestVersion: Scalars['String']; }; +export enum ViewFilterGroupLogicalOperator { + AND = 'AND', + NOT = 'NOT', + OR = 'OR' +} + +export enum ViewFilterOperand { + CONTAINS = 'CONTAINS', + DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', + GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', + IS = 'IS', + IS_AFTER = 'IS_AFTER', + IS_BEFORE = 'IS_BEFORE', + IS_EMPTY = 'IS_EMPTY', + IS_IN_FUTURE = 'IS_IN_FUTURE', + IS_IN_PAST = 'IS_IN_PAST', + IS_NOT = 'IS_NOT', + IS_NOT_EMPTY = 'IS_NOT_EMPTY', + IS_NOT_NULL = 'IS_NOT_NULL', + IS_RELATIVE = 'IS_RELATIVE', + IS_TODAY = 'IS_TODAY', + LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', + VECTOR_SEARCH = 'VECTOR_SEARCH' +} + +export enum ViewOpenRecordIn { + RECORD_PAGE = 'RECORD_PAGE', + SIDE_PANEL = 'SIDE_PANEL' +} + +export enum ViewSortDirection { + ASC = 'ASC', + DESC = 'DESC' +} + +export enum ViewType { + KANBAN = 'KANBAN', + TABLE = 'TABLE' +} + export type Webhook = { __typename?: 'Webhook'; createdAt: Scalars['DateTime']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 1a8e56a64d..55598df25a 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -18,6 +18,7 @@ export type Scalars = { DateTime: string; JSON: any; JSONObject: any; + RawJSONScalar: any; UUID: any; Upload: any; }; @@ -96,6 +97,21 @@ export type AgentIdInput = { id: Scalars['UUID']; }; +export enum AggregateOperations { + AVG = 'AVG', + COUNT = 'COUNT', + COUNT_EMPTY = 'COUNT_EMPTY', + COUNT_FALSE = 'COUNT_FALSE', + COUNT_NOT_EMPTY = 'COUNT_NOT_EMPTY', + COUNT_TRUE = 'COUNT_TRUE', + COUNT_UNIQUE_VALUES = 'COUNT_UNIQUE_VALUES', + MAX = 'MAX', + MIN = 'MIN', + PERCENTAGE_EMPTY = 'PERCENTAGE_EMPTY', + PERCENTAGE_NOT_EMPTY = 'PERCENTAGE_NOT_EMPTY', + SUM = 'SUM' +} + export type Analytics = { __typename?: 'Analytics'; /** Boolean that confirms query was dispatched */ @@ -480,6 +496,96 @@ export type ConnectionParametersOutput = { username?: Maybe; }; +export type CoreView = { + __typename?: 'CoreView'; + anyFieldFilterValue?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + icon: Scalars['String']; + id: Scalars['UUID']; + isCompact: Scalars['Boolean']; + kanbanAggregateOperation?: Maybe; + kanbanAggregateOperationFieldMetadataId?: Maybe; + key?: Maybe; + name: Scalars['String']; + objectMetadataId: Scalars['UUID']; + openRecordIn: ViewOpenRecordIn; + position: Scalars['Float']; + type: ViewType; + updatedAt: Scalars['DateTime']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewField = { + __typename?: 'CoreViewField'; + aggregateOperation?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + isVisible: Scalars['Boolean']; + position: Scalars['Float']; + size: Scalars['Float']; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewFilter = { + __typename?: 'CoreViewFilter'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + operand: ViewFilterOperand; + positionInViewFilterGroup?: Maybe; + subFieldName?: Maybe; + updatedAt: Scalars['DateTime']; + value: Scalars['RawJSONScalar']; + viewFilterGroupId?: Maybe; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewFilterGroup = { + __typename?: 'CoreViewFilterGroup'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['UUID']; + logicalOperator: ViewFilterGroupLogicalOperator; + parentViewFilterGroupId?: Maybe; + positionInViewFilterGroup?: Maybe; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewGroup = { + __typename?: 'CoreViewGroup'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + fieldMetadataId: Scalars['UUID']; + fieldValue: Scalars['String']; + id: Scalars['UUID']; + isVisible: Scalars['Boolean']; + position: Scalars['Float']; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + +export type CoreViewSort = { + __typename?: 'CoreViewSort'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + direction: ViewSortDirection; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + updatedAt: Scalars['DateTime']; + viewId: Scalars['UUID']; + workspaceId: Scalars['UUID']; +}; + export type CreateAgentChatThreadInput = { agentId: Scalars['UUID']; }; @@ -565,6 +671,60 @@ export type CreateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type CreateViewFieldInput = { + aggregateOperation?: InputMaybe; + fieldMetadataId: Scalars['UUID']; + isVisible?: InputMaybe; + position?: InputMaybe; + size?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewFilterGroupInput = { + logicalOperator?: InputMaybe; + parentViewFilterGroupId?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewFilterInput = { + fieldMetadataId: Scalars['UUID']; + operand?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + subFieldName?: InputMaybe; + value: Scalars['RawJSONScalar']; + viewFilterGroupId?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewGroupInput = { + fieldMetadataId: Scalars['UUID']; + fieldValue: Scalars['String']; + isVisible?: InputMaybe; + position?: InputMaybe; + viewId: Scalars['UUID']; +}; + +export type CreateViewInput = { + anyFieldFilterValue?: InputMaybe; + icon: Scalars['String']; + isCompact?: InputMaybe; + kanbanAggregateOperation?: InputMaybe; + kanbanAggregateOperationFieldMetadataId?: InputMaybe; + key?: InputMaybe; + name: Scalars['String']; + objectMetadataId: Scalars['UUID']; + openRecordIn?: InputMaybe; + position?: InputMaybe; + type?: InputMaybe; +}; + +export type CreateViewSortInput = { + direction?: InputMaybe; + fieldMetadataId: Scalars['UUID']; + viewId: Scalars['UUID']; +}; + export type CreateWebhookDto = { description?: InputMaybe; operations: Array; @@ -751,6 +911,7 @@ export enum FeatureFlagKey { IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', IS_API_KEY_ROLES_ENABLED = 'IS_API_KEY_ROLES_ENABLED', + IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', @@ -1125,6 +1286,12 @@ export type Mutation = { createAgentHandoff: Scalars['Boolean']; createApiKey: ApiKey; createApprovedAccessDomain: ApprovedAccessDomain; + createCoreView: CoreView; + createCoreViewField: CoreViewField; + createCoreViewFilter: CoreViewFilter; + createCoreViewFilterGroup: CoreViewFilterGroup; + createCoreViewGroup: CoreViewGroup; + createCoreViewSort: CoreViewSort; createDatabaseConfigVariable: Scalars['Boolean']; createDraftFromWorkflowVersion: WorkflowVersion; createFile: File; @@ -1142,6 +1309,12 @@ export type Mutation = { createWorkflowVersionStep: WorkflowVersionStepChanges; deactivateWorkflowVersion: Scalars['Boolean']; deleteApprovedAccessDomain: Scalars['Boolean']; + deleteCoreView: Scalars['Boolean']; + deleteCoreViewField: Scalars['Boolean']; + deleteCoreViewFilter: Scalars['Boolean']; + deleteCoreViewFilterGroup: Scalars['Boolean']; + deleteCoreViewGroup: Scalars['Boolean']; + deleteCoreViewSort: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteDatabaseConfigVariable: Scalars['Boolean']; deleteFile: File; @@ -1195,6 +1368,12 @@ export type Mutation = { switchToYearlyInterval: BillingUpdateOutput; trackAnalytics: Analytics; updateApiKey?: Maybe; + updateCoreView: CoreView; + updateCoreViewField: CoreViewField; + updateCoreViewFilter: CoreViewFilter; + updateCoreViewFilterGroup: CoreViewFilterGroup; + updateCoreViewGroup: CoreViewGroup; + updateCoreViewSort: CoreViewSort; updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; @@ -1285,6 +1464,36 @@ export type MutationCreateApprovedAccessDomainArgs = { }; +export type MutationCreateCoreViewArgs = { + input: CreateViewInput; +}; + + +export type MutationCreateCoreViewFieldArgs = { + input: CreateViewFieldInput; +}; + + +export type MutationCreateCoreViewFilterArgs = { + input: CreateViewFilterInput; +}; + + +export type MutationCreateCoreViewFilterGroupArgs = { + input: CreateViewFilterGroupInput; +}; + + +export type MutationCreateCoreViewGroupArgs = { + input: CreateViewGroupInput; +}; + + +export type MutationCreateCoreViewSortArgs = { + input: CreateViewSortInput; +}; + + export type MutationCreateDatabaseConfigVariableArgs = { key: Scalars['String']; value: Scalars['JSON']; @@ -1364,6 +1573,36 @@ export type MutationDeleteApprovedAccessDomainArgs = { }; +export type MutationDeleteCoreViewArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewFieldArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewFilterArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewFilterGroupArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewGroupArgs = { + id: Scalars['String']; +}; + + +export type MutationDeleteCoreViewSortArgs = { + id: Scalars['String']; +}; + + export type MutationDeleteDatabaseConfigVariableArgs = { key: Scalars['String']; }; @@ -1609,6 +1848,42 @@ export type MutationUpdateApiKeyArgs = { }; +export type MutationUpdateCoreViewArgs = { + id: Scalars['String']; + input: UpdateViewInput; +}; + + +export type MutationUpdateCoreViewFieldArgs = { + id: Scalars['String']; + input: UpdateViewFieldInput; +}; + + +export type MutationUpdateCoreViewFilterArgs = { + id: Scalars['String']; + input: UpdateViewFilterInput; +}; + + +export type MutationUpdateCoreViewFilterGroupArgs = { + id: Scalars['String']; + input: UpdateViewFilterGroupInput; +}; + + +export type MutationUpdateCoreViewGroupArgs = { + id: Scalars['String']; + input: UpdateViewGroupInput; +}; + + +export type MutationUpdateCoreViewSortArgs = { + id: Scalars['String']; + input: UpdateViewSortInput; +}; + + export type MutationUpdateDatabaseConfigVariableArgs = { key: Scalars['String']; value: Scalars['JSON']; @@ -2005,6 +2280,18 @@ export type Query = { getAvailablePackages: Scalars['JSON']; getConfigVariablesGrouped: ConfigVariablesOutput; getConnectedImapSmtpCaldavAccount: ConnectedImapSmtpCaldavAccount; + getCoreView?: Maybe; + getCoreViewField?: Maybe; + getCoreViewFields: Array; + getCoreViewFilter?: Maybe; + getCoreViewFilterGroup?: Maybe; + getCoreViewFilterGroups: Array; + getCoreViewFilters: Array; + getCoreViewGroup?: Maybe; + getCoreViewGroups: Array; + getCoreViewSort?: Maybe; + getCoreViewSorts: Array; + getCoreViews: Array; getDatabaseConfigVariable: ConfigVariable; getIndicatorHealthStatus: AdminPanelHealthServiceData; getMeteredProductsUsage: Array; @@ -2119,6 +2406,66 @@ export type QueryGetConnectedImapSmtpCaldavAccountArgs = { }; +export type QueryGetCoreViewArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFieldArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFieldsArgs = { + viewId: Scalars['String']; +}; + + +export type QueryGetCoreViewFilterArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFilterGroupArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewFilterGroupsArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewFiltersArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewGroupArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewGroupsArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewSortArgs = { + id: Scalars['String']; +}; + + +export type QueryGetCoreViewSortsArgs = { + viewId?: InputMaybe; +}; + + +export type QueryGetCoreViewsArgs = { + objectMetadataId?: InputMaybe; +}; + + export type QueryGetDatabaseConfigVariableArgs = { key: Scalars['String']; }; @@ -2729,6 +3076,58 @@ export type UpdateServerlessFunctionInput = { timeoutSeconds?: InputMaybe; }; +export type UpdateViewFieldInput = { + aggregateOperation?: InputMaybe; + isVisible?: InputMaybe; + position?: InputMaybe; + size?: InputMaybe; +}; + +export type UpdateViewFilterGroupInput = { + logicalOperator?: InputMaybe; + parentViewFilterGroupId?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + viewId?: InputMaybe; +}; + +export type UpdateViewFilterInput = { + fieldMetadataId?: InputMaybe; + operand?: InputMaybe; + positionInViewFilterGroup?: InputMaybe; + subFieldName?: InputMaybe; + value?: InputMaybe; + viewFilterGroupId?: InputMaybe; + viewId?: InputMaybe; +}; + +export type UpdateViewGroupInput = { + fieldMetadataId?: InputMaybe; + fieldValue?: InputMaybe; + isVisible?: InputMaybe; + position?: InputMaybe; + viewId?: InputMaybe; +}; + +export type UpdateViewInput = { + anyFieldFilterValue?: InputMaybe; + icon?: InputMaybe; + isCompact?: InputMaybe; + kanbanAggregateOperation?: InputMaybe; + kanbanAggregateOperationFieldMetadataId?: InputMaybe; + key?: InputMaybe; + name?: InputMaybe; + objectMetadataId?: InputMaybe; + openRecordIn?: InputMaybe; + position?: InputMaybe; + type?: InputMaybe; +}; + +export type UpdateViewSortInput = { + direction?: InputMaybe; + fieldMetadataId?: InputMaybe; + viewId?: InputMaybe; +}; + export type UpdateWebhookDto = { description?: InputMaybe; id: Scalars['UUID']; @@ -2884,6 +3283,46 @@ export type VersionInfo = { latestVersion: Scalars['String']; }; +export enum ViewFilterGroupLogicalOperator { + AND = 'AND', + NOT = 'NOT', + OR = 'OR' +} + +export enum ViewFilterOperand { + CONTAINS = 'CONTAINS', + DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', + GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', + IS = 'IS', + IS_AFTER = 'IS_AFTER', + IS_BEFORE = 'IS_BEFORE', + IS_EMPTY = 'IS_EMPTY', + IS_IN_FUTURE = 'IS_IN_FUTURE', + IS_IN_PAST = 'IS_IN_PAST', + IS_NOT = 'IS_NOT', + IS_NOT_EMPTY = 'IS_NOT_EMPTY', + IS_NOT_NULL = 'IS_NOT_NULL', + IS_RELATIVE = 'IS_RELATIVE', + IS_TODAY = 'IS_TODAY', + LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', + VECTOR_SEARCH = 'VECTOR_SEARCH' +} + +export enum ViewOpenRecordIn { + RECORD_PAGE = 'RECORD_PAGE', + SIDE_PANEL = 'SIDE_PANEL' +} + +export enum ViewSortDirection { + ASC = 'ASC', + DESC = 'DESC' +} + +export enum ViewType { + KANBAN = 'KANBAN', + TABLE = 'TABLE' +} + export type Webhook = { __typename?: 'Webhook'; createdAt: Scalars['DateTime']; diff --git a/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts b/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts index be1aec669b..89c49c61f6 100644 --- a/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts +++ b/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts @@ -1,6 +1,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Command } from 'nest-commander'; +import { ViewFilterOperand as SharedViewFilterOperand } from 'twenty-shared/types'; import { DataSource, QueryRunner, Repository } from 'typeorm'; import { @@ -9,16 +10,17 @@ import { } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; 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 { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view/enums/view-filter-group-logical-operator'; -import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in'; -import { ViewSortDirection } from 'src/engine/metadata-modules/view/enums/view-sort-direction'; -import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity'; -import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity'; -import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity'; -import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity'; -import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity'; import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity'; @@ -26,6 +28,7 @@ import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/vie import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; +import { convertViewFilterOperandToCoreOperand } from 'src/modules/view/utils/convert-view-filter-operand-to-core-operand.util'; import { transformViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/transform-view-filter-workspace-value-to-core-value'; @Command({ @@ -247,7 +250,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat id: workspaceView.id, name: workspaceView.name, objectMetadataId: workspaceView.objectMetadataId, - type: workspaceView.type, + type: workspaceView.type === 'table' ? ViewType.TABLE : ViewType.KANBAN, key: workspaceView.key, icon: workspaceView.icon, position: workspaceView.position, @@ -315,7 +318,9 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat id: filter.id, fieldMetadataId: filter.fieldMetadataId, viewId: filter.viewId, - operand: filter.operand, + operand: convertViewFilterOperandToCoreOperand( + filter.operand as SharedViewFilterOperand, + ), value: transformViewFilterWorkspaceValueToCoreValue(filter.value), viewFilterGroupId: filter.viewFilterGroupId, workspaceId, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1754578392938-updateCoreViewDataModel.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1754578392938-updateCoreViewDataModel.ts new file mode 100644 index 0000000000..6579cd6d65 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1754578392938-updateCoreViewDataModel.ts @@ -0,0 +1,197 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateCoreViewDataModel1754578392938 + implements MigrationInterface +{ + name = 'UpdateCoreViewDataModel1754578392938'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "core"."viewField_aggregateoperation_enum" RENAME TO "viewField_aggregateoperation_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."AggregateOperations" AS ENUM('MIN', 'MAX', 'AVG', 'SUM', 'COUNT', 'COUNT_UNIQUE_VALUES', 'COUNT_EMPTY', 'COUNT_NOT_EMPTY', 'COUNT_TRUE', 'COUNT_FALSE', 'PERCENTAGE_EMPTY', 'PERCENTAGE_NOT_EMPTY')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewField" ALTER COLUMN "aggregateOperation" TYPE "core"."AggregateOperations" USING "aggregateOperation"::"text"::"core"."AggregateOperations"`, + ); + await queryRunner.query( + `DROP TYPE "core"."viewField_aggregateoperation_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilter" DROP COLUMN "operand"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."ViewFilterOperand" AS ENUM('IS', 'IS_NOT_NULL', 'IS_NOT', 'LESS_THAN_OR_EQUAL', 'GREATER_THAN_OR_EQUAL', 'IS_BEFORE', 'IS_AFTER', 'CONTAINS', 'DOES_NOT_CONTAIN', 'IS_EMPTY', 'IS_NOT_EMPTY', 'IS_RELATIVE', 'IS_IN_PAST', 'IS_IN_FUTURE', 'IS_TODAY', 'VECTOR_SEARCH')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilter" ADD "operand" "core"."ViewFilterOperand" NOT NULL DEFAULT 'CONTAINS'`, + ); + await queryRunner.query( + `ALTER TYPE "core"."viewFilterGroup_logicaloperator_enum" RENAME TO "viewFilterGroup_logicaloperator_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."ViewFilterGroupLogicalOperator" AS ENUM('AND', 'OR', 'NOT')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ALTER COLUMN "logicalOperator" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ALTER COLUMN "logicalOperator" TYPE "core"."ViewFilterGroupLogicalOperator" USING "logicalOperator"::"text"::"core"."ViewFilterGroupLogicalOperator"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ALTER COLUMN "logicalOperator" SET DEFAULT 'NOT'`, + ); + await queryRunner.query( + `DROP TYPE "core"."viewFilterGroup_logicaloperator_enum_old"`, + ); + await queryRunner.query( + `ALTER TYPE "core"."viewSort_direction_enum" RENAME TO "viewSort_direction_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."ViewSortDirection" AS ENUM('ASC', 'DESC')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewSort" ALTER COLUMN "direction" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewSort" ALTER COLUMN "direction" TYPE "core"."ViewSortDirection" USING "direction"::"text"::"core"."ViewSortDirection"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewSort" ALTER COLUMN "direction" SET DEFAULT 'ASC'`, + ); + await queryRunner.query(`DROP TYPE "core"."viewSort_direction_enum_old"`); + await queryRunner.query(`ALTER TABLE "core"."view" DROP COLUMN "type"`); + await queryRunner.query( + `CREATE TYPE "core"."ViewType" AS ENUM('TABLE', 'KANBAN')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ADD "type" "core"."ViewType" NOT NULL DEFAULT 'TABLE'`, + ); + await queryRunner.query( + `ALTER TYPE "core"."view_openrecordin_enum" RENAME TO "view_openrecordin_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."ViewOpenRecordIn" AS ENUM('SIDE_PANEL', 'RECORD_PAGE')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "openRecordIn" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "openRecordIn" TYPE "core"."ViewOpenRecordIn" USING "openRecordIn"::"text"::"core"."ViewOpenRecordIn"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "openRecordIn" SET DEFAULT 'SIDE_PANEL'`, + ); + await queryRunner.query(`DROP TYPE "core"."view_openrecordin_enum_old"`); + await queryRunner.query( + `ALTER TYPE "core"."view_kanbanaggregateoperation_enum" RENAME TO "view_kanbanaggregateoperation_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."KanbanAggregateOperations" AS ENUM('MIN', 'MAX', 'AVG', 'SUM', 'COUNT', 'COUNT_UNIQUE_VALUES', 'COUNT_EMPTY', 'COUNT_NOT_EMPTY', 'COUNT_TRUE', 'COUNT_FALSE', 'PERCENTAGE_EMPTY', 'PERCENTAGE_NOT_EMPTY')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "kanbanAggregateOperation" TYPE "core"."KanbanAggregateOperations" USING "kanbanAggregateOperation"::"text"::"core"."KanbanAggregateOperations"`, + ); + await queryRunner.query( + `DROP TYPE "core"."view_kanbanaggregateoperation_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilter" ADD CONSTRAINT "FK_b518bd61175e0963370e09ef15e" FOREIGN KEY ("viewFilterGroupId") REFERENCES "core"."viewFilterGroup"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ADD CONSTRAINT "FK_6aa17342705ae5526de377bf7ed" FOREIGN KEY ("parentViewFilterGroupId") REFERENCES "core"."viewFilterGroup"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" DROP CONSTRAINT "FK_6aa17342705ae5526de377bf7ed"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilter" DROP CONSTRAINT "FK_b518bd61175e0963370e09ef15e"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."view_kanbanaggregateoperation_enum_old" AS ENUM('MIN', 'MAX', 'AVG', 'SUM', 'COUNT', 'COUNT_UNIQUE_VALUES', 'COUNT_EMPTY', 'COUNT_NOT_EMPTY', 'COUNT_TRUE', 'COUNT_FALSE', 'PERCENTAGE_EMPTY', 'PERCENTAGE_NOT_EMPTY')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "kanbanAggregateOperation" TYPE "core"."view_kanbanaggregateoperation_enum_old" USING "kanbanAggregateOperation"::"text"::"core"."view_kanbanaggregateoperation_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "core"."KanbanAggregateOperations"`); + await queryRunner.query( + `ALTER TYPE "core"."view_kanbanaggregateoperation_enum_old" RENAME TO "view_kanbanaggregateoperation_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."view_openrecordin_enum_old" AS ENUM('SIDE_PANEL', 'RECORD_PAGE')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "openRecordIn" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "openRecordIn" TYPE "core"."view_openrecordin_enum_old" USING "openRecordIn"::"text"::"core"."view_openrecordin_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."view" ALTER COLUMN "openRecordIn" SET DEFAULT 'SIDE_PANEL'`, + ); + await queryRunner.query(`DROP TYPE "core"."ViewOpenRecordIn"`); + await queryRunner.query( + `ALTER TYPE "core"."view_openrecordin_enum_old" RENAME TO "view_openrecordin_enum"`, + ); + await queryRunner.query(`ALTER TABLE "core"."view" DROP COLUMN "type"`); + await queryRunner.query(`DROP TYPE "core"."ViewType"`); + await queryRunner.query( + `ALTER TABLE "core"."view" ADD "type" character varying NOT NULL DEFAULT 'table'`, + ); + await queryRunner.query( + `CREATE TYPE "core"."viewSort_direction_enum_old" AS ENUM('ASC', 'DESC')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewSort" ALTER COLUMN "direction" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewSort" ALTER COLUMN "direction" TYPE "core"."viewSort_direction_enum_old" USING "direction"::"text"::"core"."viewSort_direction_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewSort" ALTER COLUMN "direction" SET DEFAULT 'ASC'`, + ); + await queryRunner.query(`DROP TYPE "core"."ViewSortDirection"`); + await queryRunner.query( + `ALTER TYPE "core"."viewSort_direction_enum_old" RENAME TO "viewSort_direction_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."viewFilterGroup_logicaloperator_enum_old" AS ENUM('AND', 'OR', 'NOT')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ALTER COLUMN "logicalOperator" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ALTER COLUMN "logicalOperator" TYPE "core"."viewFilterGroup_logicaloperator_enum_old" USING "logicalOperator"::"text"::"core"."viewFilterGroup_logicaloperator_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilterGroup" ALTER COLUMN "logicalOperator" SET DEFAULT 'NOT'`, + ); + await queryRunner.query( + `DROP TYPE "core"."ViewFilterGroupLogicalOperator"`, + ); + await queryRunner.query( + `ALTER TYPE "core"."viewFilterGroup_logicaloperator_enum_old" RENAME TO "viewFilterGroup_logicaloperator_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewFilter" DROP COLUMN "operand"`, + ); + await queryRunner.query(`DROP TYPE "core"."ViewFilterOperand"`); + await queryRunner.query( + `ALTER TABLE "core"."viewFilter" ADD "operand" character varying NOT NULL DEFAULT 'Contains'`, + ); + await queryRunner.query( + `CREATE TYPE "core"."viewField_aggregateoperation_enum_old" AS ENUM('MIN', 'MAX', 'AVG', 'SUM', 'COUNT', 'COUNT_UNIQUE_VALUES', 'COUNT_EMPTY', 'COUNT_NOT_EMPTY', 'COUNT_TRUE', 'COUNT_FALSE', 'PERCENTAGE_EMPTY', 'PERCENTAGE_NOT_EMPTY')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."viewField" ALTER COLUMN "aggregateOperation" TYPE "core"."viewField_aggregateoperation_enum_old" USING "aggregateOperation"::"text"::"core"."viewField_aggregateoperation_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "core"."AggregateOperations"`); + await queryRunner.query( + `ALTER TYPE "core"."viewField_aggregateoperation_enum_old" RENAME TO "viewField_aggregateoperation_enum"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 9c527fa4d3..55c5c7b860 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -42,6 +42,7 @@ import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.mod import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { UserModule } from 'src/engine/core-modules/user/user.module'; +import { CoreViewModule } from 'src/engine/core-modules/view/view.module'; import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module'; import { WorkflowApiModule } from 'src/engine/core-modules/workflow/workflow-api.module'; import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; @@ -118,6 +119,7 @@ import { FileModule } from './file/file.module'; SearchModule, ApiKeyModule, WebhookModule, + CoreViewModule, ], exports: [ AuditModule, diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index a4b0dd076a..9bf1a216b1 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -12,6 +12,7 @@ export enum FeatureFlagKey { IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED', + IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED', IS_TWO_FACTOR_AUTHENTICATION_ENABLED = 'IS_TWO_FACTOR_AUTHENTICATION_ENABLED', IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED', IS_API_KEY_ROLES_ENABLED = 'IS_API_KEY_ROLES_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 631b9827e3..d2ea0ba113 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -196,6 +196,30 @@ export class OpenApiService { nameSingular: 'apiKey', namePlural: 'apiKeys', }, + { + nameSingular: 'view', + namePlural: 'views', + }, + { + nameSingular: 'viewField', + namePlural: 'viewFields', + }, + { + nameSingular: 'viewFilter', + namePlural: 'viewFilters', + }, + { + nameSingular: 'viewSort', + namePlural: 'viewSorts', + }, + { + nameSingular: 'viewGroup', + namePlural: 'viewGroups', + }, + { + nameSingular: 'viewFilterGroup', + namePlural: 'viewFilterGroups', + }, ]; schema.paths = metadata.reduce((path, item) => { diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view-field.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view-field.controller.ts new file mode 100644 index 0000000000..aeb0396ea0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view-field.controller.ts @@ -0,0 +1,90 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewFieldInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-field.input'; +import { UpdateViewFieldInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-field.input'; +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFieldRestApiExceptionFilter } from 'src/engine/core-modules/view/filters/view-field-rest-api-exception.filter'; +import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/viewFields') +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(ViewFieldRestApiExceptionFilter) +export class ViewFieldController { + constructor(private readonly viewFieldService: ViewFieldService) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('viewId') viewId?: string, + ): Promise { + 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 { + return this.viewFieldService.findById(id, workspace.id); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdateViewFieldInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const updatedViewField = await this.viewFieldService.update( + id, + workspace.id, + input, + ); + + return updatedViewField; + } + + @Post() + async create( + @Body() input: CreateViewFieldInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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) }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view-filter-group.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view-filter-group.controller.ts new file mode 100644 index 0000000000..2fe86bab65 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view-filter-group.controller.ts @@ -0,0 +1,92 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewFilterGroupInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-filter-group.input'; +import { UpdateViewFilterGroupInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-filter-group.input'; +import { ViewFilterGroupDTO } from 'src/engine/core-modules/view/dtos/view-filter-group.dto'; +import { ViewFilterGroupRestApiExceptionFilter } from 'src/engine/core-modules/view/filters/view-filter-group-rest-api-exception.filter'; +import { ViewFilterGroupService } from 'src/engine/core-modules/view/services/view-filter-group.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/viewFilterGroups') +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(ViewFilterGroupRestApiExceptionFilter) +export class ViewFilterGroupController { + constructor( + private readonly viewFilterGroupService: ViewFilterGroupService, + ) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('viewId') viewId?: string, + ): Promise { + 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 { + return this.viewFilterGroupService.findById(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreateViewFilterGroupInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewFilterGroupService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdateViewFilterGroupInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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) }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view-filter.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view-filter.controller.ts new file mode 100644 index 0000000000..e6b791ab99 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view-filter.controller.ts @@ -0,0 +1,90 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewFilterInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-filter.input'; +import { UpdateViewFilterInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-filter.input'; +import { ViewFilterDTO } from 'src/engine/core-modules/view/dtos/view-filter.dto'; +import { ViewFilterRestApiExceptionFilter } from 'src/engine/core-modules/view/filters/view-filter-rest-api-exception.filter'; +import { ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/viewFilters') +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(ViewFilterRestApiExceptionFilter) +export class ViewFilterController { + constructor(private readonly viewFilterService: ViewFilterService) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('viewId') viewId?: string, + ): Promise { + 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 { + return this.viewFilterService.findById(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreateViewFilterInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewFilterService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdateViewFilterInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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) }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view-group.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view-group.controller.ts new file mode 100644 index 0000000000..2052cab36f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view-group.controller.ts @@ -0,0 +1,90 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewGroupInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-group.input'; +import { UpdateViewGroupInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-group.input'; +import { ViewGroupDTO } from 'src/engine/core-modules/view/dtos/view-group.dto'; +import { ViewGroupRestApiExceptionFilter } from 'src/engine/core-modules/view/filters/view-group-rest-api-exception.filter'; +import { ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/viewGroups') +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(ViewGroupRestApiExceptionFilter) +export class ViewGroupController { + constructor(private readonly viewGroupService: ViewGroupService) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('viewId') viewId?: string, + ): Promise { + 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 { + return this.viewGroupService.findById(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreateViewGroupInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewGroupService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdateViewGroupInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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) }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view-sort.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view-sort.controller.ts new file mode 100644 index 0000000000..d6d9c0352b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view-sort.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewSortInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-sort.input'; +import { UpdateViewSortInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-sort.input'; +import { ViewSortDTO } from 'src/engine/core-modules/view/dtos/view-sort.dto'; +import { ViewSortRestApiExceptionFilter } from 'src/engine/core-modules/view/filters/view-sort-rest-api-exception.filter'; +import { ViewSortService } from 'src/engine/core-modules/view/services/view-sort.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/viewSorts') +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(ViewSortRestApiExceptionFilter) +export class ViewSortController { + constructor(private readonly viewSortService: ViewSortService) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('viewId') viewId?: string, + ): Promise { + 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 { + return this.viewSortService.findById(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreateViewSortInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewSortService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdateViewSortInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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) }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts new file mode 100644 index 0000000000..a617ca3f7e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewInput } from 'src/engine/core-modules/view/dtos/inputs/create-view.input'; +import { UpdateViewInput } from 'src/engine/core-modules/view/dtos/inputs/update-view.input'; +import { ViewDTO } from 'src/engine/core-modules/view/dtos/view.dto'; +import { ViewRestApiExceptionFilter } from 'src/engine/core-modules/view/filters/view-rest-api-exception.filter'; +import { ViewService } from 'src/engine/core-modules/view/services/view.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/views') +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseFilters(ViewRestApiExceptionFilter) +export class ViewController { + constructor(private readonly viewService: ViewService) {} + + @Get() + @UseGuards(WorkspaceAuthGuard) + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('objectMetadataId') objectMetadataId?: string, + ): Promise { + if (objectMetadataId) { + return this.viewService.findByObjectMetadataId( + workspace.id, + objectMetadataId, + ); + } + + return this.viewService.findByWorkspaceId(workspace.id); + } + + @Get(':id') + async findOne( + @Param('id') id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewService.findById(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreateViewInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdateViewInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const updatedView = await this.viewService.update(id, workspace.id, input); + + return updatedView; + } + + @Delete(':id') + async delete( + @Param('id') id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise<{ success: boolean }> { + const deletedView = await this.viewService.delete(id, workspace.id); + + return { success: isDefined(deletedView) }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-field.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-field.input.ts new file mode 100644 index 0000000000..37e43e608b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-field.input.ts @@ -0,0 +1,25 @@ +import { Field, InputType } from '@nestjs/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'; + +@InputType() +export class CreateViewFieldInput { + @Field(() => UUIDScalarType, { nullable: false }) + fieldMetadataId: string; + + @Field(() => UUIDScalarType, { nullable: false }) + viewId: string; + + @Field({ nullable: true, defaultValue: true }) + isVisible?: boolean; + + @Field({ nullable: true, defaultValue: 0 }) + size?: number; + + @Field({ nullable: true, defaultValue: 0 }) + position?: number; + + @Field(() => AggregateOperations, { nullable: true }) + aggregateOperation?: AggregateOperations; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter-group.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter-group.input.ts new file mode 100644 index 0000000000..cd69486f5d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter-group.input.ts @@ -0,0 +1,22 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; + +@InputType() +export class CreateViewFilterGroupInput { + @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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter.input.ts new file mode 100644 index 0000000000..8572ac9d2f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-filter.input.ts @@ -0,0 +1,30 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { RawJSONScalar } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/raw-json.scalar'; +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { ViewFilterValue } from 'src/engine/core-modules/view/types/view-filter-value.type'; + +@InputType() +export class CreateViewFilterInput { + @Field(() => UUIDScalarType, { nullable: false }) + fieldMetadataId: string; + + @Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS }) + operand?: ViewFilterOperand; + + @Field(() => RawJSONScalar, { 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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-group.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-group.input.ts new file mode 100644 index 0000000000..304a8d4cb4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-group.input.ts @@ -0,0 +1,21 @@ +import { InputType, Field } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class CreateViewGroupInput { + @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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-sort.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-sort.input.ts new file mode 100644 index 0000000000..65a33eceb4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view-sort.input.ts @@ -0,0 +1,19 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; + +@InputType() +export class CreateViewSortInput { + @Field(() => UUIDScalarType, { nullable: false }) + fieldMetadataId: string; + + @Field(() => ViewSortDirection, { + nullable: true, + defaultValue: ViewSortDirection.ASC, + }) + direction?: ViewSortDirection; + + @Field(() => UUIDScalarType, { nullable: false }) + viewId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view.input.ts new file mode 100644 index 0000000000..37c2bf0080 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/create-view.input.ts @@ -0,0 +1,45 @@ +import { Field, InputType } from '@nestjs/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'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; + +@InputType() +export class CreateViewInput { + @Field({ nullable: false }) + name: string; + + @Field(() => UUIDScalarType, { nullable: false }) + objectMetadataId: string; + + @Field(() => ViewType, { nullable: true, defaultValue: ViewType.TABLE }) + type?: ViewType; + + @Field({ nullable: true, defaultValue: 'INDEX' }) + key?: string; + + @Field({ nullable: false }) + icon: string; + + @Field({ nullable: true, defaultValue: 0 }) + position?: number; + + @Field({ nullable: true, defaultValue: false }) + isCompact?: boolean; + + @Field(() => ViewOpenRecordIn, { + nullable: true, + defaultValue: ViewOpenRecordIn.SIDE_PANEL, + }) + openRecordIn?: ViewOpenRecordIn; + + @Field(() => AggregateOperations, { nullable: true }) + kanbanAggregateOperation?: AggregateOperations; + + @Field(() => UUIDScalarType, { nullable: true }) + kanbanAggregateOperationFieldMetadataId?: string; + + @Field({ nullable: true }) + anyFieldFilterValue?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-field.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-field.input.ts new file mode 100644 index 0000000000..659f66f21c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-field.input.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; + +@InputType() +export class UpdateViewFieldInput { + @Field({ nullable: true }) + isVisible?: boolean; + + @Field({ nullable: true }) + size?: number; + + @Field({ nullable: true }) + position?: number; + + @Field(() => AggregateOperations, { nullable: true }) + aggregateOperation?: AggregateOperations; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter-group.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter-group.input.ts new file mode 100644 index 0000000000..1bb8d9543c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter-group.input.ts @@ -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, +) {} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter.input.ts new file mode 100644 index 0000000000..6dd2c798f0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-filter.input.ts @@ -0,0 +1,6 @@ +import { InputType, PartialType } from '@nestjs/graphql'; + +import { CreateViewFilterInput } from './create-view-filter.input'; + +@InputType() +export class UpdateViewFilterInput extends PartialType(CreateViewFilterInput) {} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-group.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-group.input.ts new file mode 100644 index 0000000000..49b107aec2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-group.input.ts @@ -0,0 +1,6 @@ +import { InputType, PartialType } from '@nestjs/graphql'; + +import { CreateViewGroupInput } from './create-view-group.input'; + +@InputType() +export class UpdateViewGroupInput extends PartialType(CreateViewGroupInput) {} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-sort.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-sort.input.ts new file mode 100644 index 0000000000..8934c6f22f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view-sort.input.ts @@ -0,0 +1,6 @@ +import { InputType, PartialType } from '@nestjs/graphql'; + +import { CreateViewSortInput } from './create-view-sort.input'; + +@InputType() +export class UpdateViewSortInput extends PartialType(CreateViewSortInput) {} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view.input.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view.input.ts new file mode 100644 index 0000000000..3eaf34ebd8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/inputs/update-view.input.ts @@ -0,0 +1,6 @@ +import { InputType, PartialType } from '@nestjs/graphql'; + +import { CreateViewInput } from './create-view.input'; + +@InputType() +export class UpdateViewInput extends PartialType(CreateViewInput) {} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/view-field.dto.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/view-field.dto.ts new file mode 100644 index 0000000000..df94709772 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/view-field.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/view-filter-group.dto.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/view-filter-group.dto.ts new file mode 100644 index 0000000000..5819901a82 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/view-filter-group.dto.ts @@ -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/core-modules/view/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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/view-filter.dto.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/view-filter.dto.ts new file mode 100644 index 0000000000..3d01975c6e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/view-filter.dto.ts @@ -0,0 +1,53 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { + RawJSONScalar, + UUIDScalarType, +} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { ViewFilterValue } from 'src/engine/core-modules/view/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(() => RawJSONScalar, { 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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/view-group.dto.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/view-group.dto.ts new file mode 100644 index 0000000000..562c87c6ec --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/view-group.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/view-sort.dto.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/view-sort.dto.ts new file mode 100644 index 0000000000..1911ac25cc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/view-sort.dto.ts @@ -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/core-modules/view/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; +} diff --git a/packages/twenty-server/src/engine/core-modules/view/dtos/view.dto.ts b/packages/twenty-server/src/engine/core-modules/view/dtos/view.dto.ts new file mode 100644 index 0000000000..5f092996d3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/dtos/view.dto.ts @@ -0,0 +1,65 @@ +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'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; + +registerEnumType(ViewOpenRecordIn, { name: 'ViewOpenRecordIn' }); +registerEnumType(ViewType, { name: 'ViewType' }); + +@ObjectType('CoreView') +export class ViewDTO { + @IDField(() => UUIDScalarType) + id: string; + + @Field({ nullable: false }) + name: string; + + @Field(() => UUIDScalarType, { nullable: false }) + objectMetadataId: string; + + @Field(() => ViewType, { nullable: false, defaultValue: ViewType.TABLE }) + type: ViewType; + + @Field({ nullable: true, defaultValue: 'INDEX' }) + key: string; + + @Field({ nullable: false }) + icon: string; + + @Field({ nullable: false, defaultValue: 0 }) + position: number; + + @Field({ nullable: false, defaultValue: false }) + isCompact: boolean; + + @Field(() => ViewOpenRecordIn, { + nullable: false, + defaultValue: ViewOpenRecordIn.SIDE_PANEL, + }) + openRecordIn: ViewOpenRecordIn; + + @Field(() => AggregateOperations, { nullable: true }) + kanbanAggregateOperation?: AggregateOperations | null; + + @Field(() => UUIDScalarType, { nullable: true }) + kanbanAggregateOperationFieldMetadataId?: string | null; + + @Field(() => UUIDScalarType, { nullable: false }) + workspaceId: string; + + @Field(() => String, { nullable: true }) + anyFieldFilterValue?: string | null; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; + + @Field(() => Date, { nullable: true }) + deletedAt?: Date | null; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view-field.entity.ts b/packages/twenty-server/src/engine/core-modules/view/entities/view-field.entity.ts similarity index 93% rename from packages/twenty-server/src/engine/metadata-modules/view/view-field.entity.ts rename to packages/twenty-server/src/engine/core-modules/view/entities/view-field.entity.ts index e00e807251..cdcf549133 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view-field.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/view/entities/view-field.entity.ts @@ -14,8 +14,8 @@ import { 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'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; @Entity({ name: 'viewField', schema: 'core' }) @Index('IDX_VIEW_FIELD_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId']) @@ -46,8 +46,10 @@ export class ViewField { @Column({ type: 'enum', + enumName: 'AggregateOperations', enum: AggregateOperations, nullable: true, + default: null, }) aggregateOperation?: AggregateOperations | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view-filter-group.entity.ts b/packages/twenty-server/src/engine/core-modules/view/entities/view-filter-group.entity.ts similarity index 65% rename from packages/twenty-server/src/engine/metadata-modules/view/view-filter-group.entity.ts rename to packages/twenty-server/src/engine/core-modules/view/entities/view-filter-group.entity.ts index 1e2869c8b4..210d1edca1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view-filter-group.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/view/entities/view-filter-group.entity.ts @@ -1,5 +1,3 @@ -import { registerEnumType } from '@nestjs/graphql'; - import { IDField } from '@ptc-org/nestjs-query-graphql'; import { Column, @@ -9,19 +7,17 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, Relation, UpdateDateColumn, } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view/enums/view-filter-group-logical-operator'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; - -registerEnumType(ViewFilterGroupLogicalOperator, { - name: 'ViewFilterGroupLogicalOperator', -}); @Entity({ name: 'viewFilterGroup', schema: 'core' }) @Index('IDX_VIEW_FILTER_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId']) @@ -35,6 +31,7 @@ export class ViewFilterGroup { @Column({ type: 'enum', + enumName: 'ViewFilterGroupLogicalOperator', enum: ViewFilterGroupLogicalOperator, nullable: false, default: ViewFilterGroupLogicalOperator.NOT, @@ -70,4 +67,23 @@ export class ViewFilterGroup { }) @JoinColumn({ name: 'viewId' }) view: Relation; + + @OneToMany(() => ViewFilter, (viewFilter) => viewFilter.viewFilterGroup) + viewFilters: Relation[]; + + @ManyToOne( + () => ViewFilterGroup, + (viewFilterGroup) => viewFilterGroup.childViewFilterGroups, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'parentViewFilterGroupId' }) + parentViewFilterGroup: Relation; + + @OneToMany( + () => ViewFilterGroup, + (viewFilterGroup) => viewFilterGroup.parentViewFilterGroup, + ) + childViewFilterGroups: Relation[]; } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts b/packages/twenty-server/src/engine/core-modules/view/entities/view-filter.entity.ts similarity index 69% rename from packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts rename to packages/twenty-server/src/engine/core-modules/view/entities/view-filter.entity.ts index 1c36e4e529..427fd4692b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/view/entities/view-filter.entity.ts @@ -13,9 +13,11 @@ import { } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { ViewFilterValue } from 'src/engine/core-modules/view/types/view-filter-value.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ViewFilterValue } from 'src/engine/metadata-modules/view/types/view-filter-value.type'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; @Entity({ name: 'viewFilter', schema: 'core' }) @Index('IDX_VIEW_FILTER_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId']) @@ -28,8 +30,14 @@ export class ViewFilter { @Column({ nullable: false, type: 'uuid' }) fieldMetadataId: string; - @Column({ nullable: false, default: 'Contains' }) - operand: string; + @Column({ + nullable: false, + type: 'enum', + enumName: 'ViewFilterOperand', + enum: ViewFilterOperand, + default: ViewFilterOperand.CONTAINS, + }) + operand: ViewFilterOperand; @Column({ nullable: false, type: 'jsonb' }) value: ViewFilterValue; @@ -69,4 +77,14 @@ export class ViewFilter { }) @JoinColumn({ name: 'viewId' }) view: Relation; + + @ManyToOne( + () => ViewFilterGroup, + (viewFilterGroup) => viewFilterGroup.viewFilters, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'viewFilterGroupId' }) + viewFilterGroup: Relation; } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view-group.entity.ts b/packages/twenty-server/src/engine/core-modules/view/entities/view-group.entity.ts similarity index 95% rename from packages/twenty-server/src/engine/metadata-modules/view/view-group.entity.ts rename to packages/twenty-server/src/engine/core-modules/view/entities/view-group.entity.ts index a6088e3f14..b9f9252e35 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view-group.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/view/entities/view-group.entity.ts @@ -13,8 +13,8 @@ import { } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; @Entity({ name: 'viewGroup', schema: 'core' }) @Index('IDX_VIEW_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId']) diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view-sort.entity.ts b/packages/twenty-server/src/engine/core-modules/view/entities/view-sort.entity.ts similarity index 85% rename from packages/twenty-server/src/engine/metadata-modules/view/view-sort.entity.ts rename to packages/twenty-server/src/engine/core-modules/view/entities/view-sort.entity.ts index 8037174940..0f04236866 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view-sort.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/view/entities/view-sort.entity.ts @@ -1,5 +1,3 @@ -import { registerEnumType } from '@nestjs/graphql'; - import { IDField } from '@ptc-org/nestjs-query-graphql'; import { Column, @@ -15,11 +13,9 @@ import { } from 'typeorm'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ViewSortDirection } from 'src/engine/metadata-modules/view/enums/view-sort-direction'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; - -registerEnumType(ViewSortDirection, { name: 'ViewSortDirection' }); @Entity({ name: 'viewSort', schema: 'core' }) @Index('IDX_VIEW_SORT_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId']) @@ -42,6 +38,7 @@ export class ViewSort { @Column({ nullable: false, type: 'enum', + enumName: 'ViewSortDirection', enum: ViewSortDirection, default: ViewSortDirection.ASC, }) diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts b/packages/twenty-server/src/engine/core-modules/view/entities/view.entity.ts similarity index 75% rename from packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts rename to packages/twenty-server/src/engine/core-modules/view/entities/view.entity.ts index d9b7e16949..8255ffda45 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/view/entities/view.entity.ts @@ -1,5 +1,3 @@ -import { registerEnumType } from '@nestjs/graphql'; - import { IDField } from '@ptc-org/nestjs-query-graphql'; import { Column, @@ -17,15 +15,14 @@ import { 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'; +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in'; -import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity'; -import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity'; -import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity'; -import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity'; -import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity'; - -registerEnumType(ViewOpenRecordIn, { name: 'ViewOpenRecordIn' }); @Entity({ name: 'view', schema: 'core' }) @Index('IDX_VIEW_WORKSPACE_ID_OBJECT_METADATA_ID', [ @@ -43,8 +40,14 @@ export class View { @Column({ nullable: false, type: 'uuid' }) objectMetadataId: string; - @Column({ nullable: false, default: 'table' }) - type: string; + @Column({ + type: 'enum', + enum: ViewType, + enumName: 'ViewType', + nullable: false, + default: ViewType.TABLE, + }) + type: ViewType; @Column({ nullable: true, type: 'text', default: 'INDEX' }) key: string; @@ -60,6 +63,7 @@ export class View { @Column({ type: 'enum', + enumName: 'ViewOpenRecordIn', enum: ViewOpenRecordIn, nullable: false, default: ViewOpenRecordIn.SIDE_PANEL, @@ -68,8 +72,10 @@ export class View { @Column({ type: 'enum', + enumName: 'KanbanAggregateOperations', enum: AggregateOperations, nullable: true, + default: null, }) kanbanAggregateOperation?: AggregateOperations | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/enums/view-filter-group-logical-operator.ts b/packages/twenty-server/src/engine/core-modules/view/enums/view-filter-group-logical-operator.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/view/enums/view-filter-group-logical-operator.ts rename to packages/twenty-server/src/engine/core-modules/view/enums/view-filter-group-logical-operator.ts diff --git a/packages/twenty-server/src/engine/core-modules/view/enums/view-filter-operand.ts b/packages/twenty-server/src/engine/core-modules/view/enums/view-filter-operand.ts new file mode 100644 index 0000000000..4b6a3a40c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/enums/view-filter-operand.ts @@ -0,0 +1,18 @@ +export enum ViewFilterOperand { + IS = 'IS', + IS_NOT_NULL = 'IS_NOT_NULL', + IS_NOT = 'IS_NOT', + LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', + GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', + IS_BEFORE = 'IS_BEFORE', + IS_AFTER = 'IS_AFTER', + CONTAINS = 'CONTAINS', + DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', + IS_EMPTY = 'IS_EMPTY', + IS_NOT_EMPTY = 'IS_NOT_EMPTY', + IS_RELATIVE = 'IS_RELATIVE', + IS_IN_PAST = 'IS_IN_PAST', + IS_IN_FUTURE = 'IS_IN_FUTURE', + IS_TODAY = 'IS_TODAY', + VECTOR_SEARCH = 'VECTOR_SEARCH', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/enums/view-open-record-in.ts b/packages/twenty-server/src/engine/core-modules/view/enums/view-open-record-in.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/view/enums/view-open-record-in.ts rename to packages/twenty-server/src/engine/core-modules/view/enums/view-open-record-in.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/view/enums/view-sort-direction.ts b/packages/twenty-server/src/engine/core-modules/view/enums/view-sort-direction.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/view/enums/view-sort-direction.ts rename to packages/twenty-server/src/engine/core-modules/view/enums/view-sort-direction.ts diff --git a/packages/twenty-server/src/engine/core-modules/view/enums/view-type.enum.ts b/packages/twenty-server/src/engine/core-modules/view/enums/view-type.enum.ts new file mode 100644 index 0000000000..ece12a631c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/enums/view-type.enum.ts @@ -0,0 +1,4 @@ +export enum ViewType { + TABLE = 'TABLE', + KANBAN = 'KANBAN', +} diff --git a/packages/twenty-server/src/engine/core-modules/view/exceptions/view-field.exception.ts b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-field.exception.ts new file mode 100644 index 0000000000..e996e9f378 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-field.exception.ts @@ -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 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', +} + +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'; + 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.`; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter-group.exception.ts b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter-group.exception.ts new file mode 100644 index 0000000000..81c86655e6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter-group.exception.ts @@ -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.`; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter.exception.ts b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter.exception.ts new file mode 100644 index 0000000000..c93b16c33c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-filter.exception.ts @@ -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.`; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/exceptions/view-group.exception.ts b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-group.exception.ts new file mode 100644 index 0000000000..1a4ce0eeca --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-group.exception.ts @@ -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.`; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/exceptions/view-sort.exception.ts b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-sort.exception.ts new file mode 100644 index 0000000000..4805ff38e2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/exceptions/view-sort.exception.ts @@ -0,0 +1,70 @@ +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 = `WorkspaceId is required`; + break; + case ViewSortExceptionMessageKey.VIEW_ID_REQUIRED: + message = `ViewId is required`; + break; + case ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND: + message = `View sort${id ? ` (id: ${id})` : ''} not found`; + break; + case ViewSortExceptionMessageKey.INVALID_VIEW_SORT_DATA: + message = `Invalid view sort data${id ? ` for view sort id: ${id}` : ''}`; + break; + case ViewSortExceptionMessageKey.FIELD_METADATA_ID_REQUIRED: + message = `FieldMetadataId is required`; + break; + default: + assertUnreachable(key); + } + + return t`${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.`; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/exceptions/view.exception.ts b/packages/twenty-server/src/engine/core-modules/view/exceptions/view.exception.ts new file mode 100644 index 0000000000..68049918b2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/exceptions/view.exception.ts @@ -0,0 +1,56 @@ +import { t } from '@lingui/core/macro'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export class ViewException extends CustomException { + declare code: ViewExceptionCode; + constructor( + message: string, + code: ViewExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: string } = {}, + ) { + super(message, code, { userFriendlyMessage }); + } +} + +export enum ViewExceptionCode { + VIEW_NOT_FOUND = 'VIEW_NOT_FOUND', + INVALID_VIEW_DATA = 'INVALID_VIEW_DATA', +} + +export enum ViewExceptionMessageKey { + WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED', + OBJECT_METADATA_ID_REQUIRED = 'OBJECT_METADATA_ID_REQUIRED', + VIEW_NOT_FOUND = 'VIEW_NOT_FOUND', + INVALID_VIEW_DATA = 'INVALID_VIEW_DATA', +} + +export const generateViewExceptionMessage = ( + key: ViewExceptionMessageKey, + id?: string, +) => { + switch (key) { + case ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED: + return 'WorkspaceId is required'; + case ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED: + return 'ObjectMetadataId is required'; + case ViewExceptionMessageKey.VIEW_NOT_FOUND: + return `View${id ? ` (id: ${id})` : ''} not found`; + case ViewExceptionMessageKey.INVALID_VIEW_DATA: + return `Invalid view data${id ? ` for view id: ${id}` : ''}`; + default: + assertUnreachable(key); + } +}; + +export const generateViewUserFriendlyExceptionMessage = ( + key: ViewExceptionMessageKey, +) => { + switch (key) { + case ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED: + return t`WorkspaceId is required to create a view.`; + case ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED: + return t`ObjectMetadataId is required to create a view.`; + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/filters/view-field-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/filters/view-field-rest-api-exception.filter.ts new file mode 100644 index 0000000000..cc70717a9c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/filters/view-field-rest-api-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + ViewFieldException, + ViewFieldExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-field.exception'; +import { 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(); + + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/filters/view-filter-group-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/filters/view-filter-group-rest-api-exception.filter.ts new file mode 100644 index 0000000000..a6672c1bda --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/filters/view-filter-group-rest-api-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + ViewFilterGroupException, + ViewFilterGroupExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; +import { 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(); + + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/filters/view-filter-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/filters/view-filter-rest-api-exception.filter.ts new file mode 100644 index 0000000000..98360cc304 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/filters/view-filter-rest-api-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + ViewFilterException, + ViewFilterExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-filter.exception'; +import { 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(); + + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/filters/view-group-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/filters/view-group-rest-api-exception.filter.ts new file mode 100644 index 0000000000..d195de707e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/filters/view-group-rest-api-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + ViewGroupException, + ViewGroupExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-group.exception'; +import { 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(); + + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/filters/view-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/filters/view-rest-api-exception.filter.ts new file mode 100644 index 0000000000..4b685e321f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/filters/view-rest-api-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + ViewException, + ViewExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view.exception'; +import { CustomException } from 'src/utils/custom-exception'; + +@Catch(ViewException) +export class ViewRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + ) {} + + catch(exception: ViewException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + switch (exception.code) { + case ViewExceptionCode.VIEW_NOT_FOUND: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 404, + ); + case ViewExceptionCode.INVALID_VIEW_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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/filters/view-sort-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/filters/view-sort-rest-api-exception.filter.ts new file mode 100644 index 0000000000..a7c9068844 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/filters/view-sort-rest-api-exception.filter.ts @@ -0,0 +1,44 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; + +import { Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + ViewSortException, + ViewSortExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-sort.exception'; +import { 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(); + + 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, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-field.resolver.ts new file mode 100644 index 0000000000..8f5f3eaeb7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-field.resolver.ts @@ -0,0 +1,70 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewFieldInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-field.input'; +import { UpdateViewFieldInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-field.input'; +import { ViewFieldDTO } from 'src/engine/core-modules/view/dtos/view-field.dto'; +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service'; +import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/utils/view-graphql-api-exception.filter'; +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'; + +@Resolver(() => ViewFieldDTO) +@UseFilters(ViewGraphqlApiExceptionFilter) +@UseGuards(WorkspaceAuthGuard) +export class ViewFieldResolver { + constructor(private readonly viewFieldService: ViewFieldService) {} + + @Query(() => [ViewFieldDTO]) + async getCoreViewFields( + @Args('viewId', { type: () => String }) viewId: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewFieldService.findByViewId(workspace.id, viewId); + } + + @Query(() => ViewFieldDTO, { nullable: true }) + async getCoreViewField( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewFieldService.findById(id, workspace.id); + } + + @Mutation(() => ViewFieldDTO) + async updateCoreViewField( + @Args('id', { type: () => String }) id: string, + @Args('input') input: UpdateViewFieldInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewFieldService.update(id, workspace.id, input); + } + + @Mutation(() => ViewFieldDTO) + async createCoreViewField( + @Args('input') input: CreateViewFieldInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewFieldService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Mutation(() => Boolean) + async deleteCoreViewField( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedViewField = await this.viewFieldService.delete( + id, + workspace.id, + ); + + return isDefined(deletedViewField); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter-group.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter-group.resolver.ts new file mode 100644 index 0000000000..1c5f2e313d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter-group.resolver.ts @@ -0,0 +1,76 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewFilterGroupInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-filter-group.input'; +import { UpdateViewFilterGroupInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-filter-group.input'; +import { ViewFilterGroupDTO } from 'src/engine/core-modules/view/dtos/view-filter-group.dto'; +import { ViewFilterGroupService } from 'src/engine/core-modules/view/services/view-filter-group.service'; +import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/utils/view-graphql-api-exception.filter'; +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'; + +@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 { + 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 { + return this.viewFilterGroupService.findById(id, workspace.id); + } + + @Mutation(() => ViewFilterGroupDTO) + async createCoreViewFilterGroup( + @Args('input') input: CreateViewFilterGroupInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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 { + return this.viewFilterGroupService.update(id, workspace.id, input); + } + + @Mutation(() => Boolean) + async deleteCoreViewFilterGroup( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedViewFilterGroup = await this.viewFilterGroupService.delete( + id, + workspace.id, + ); + + return isDefined(deletedViewFilterGroup); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter.resolver.ts new file mode 100644 index 0000000000..80c8a90bc8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-filter.resolver.ts @@ -0,0 +1,74 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewFilterInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-filter.input'; +import { UpdateViewFilterInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-filter.input'; +import { ViewFilterDTO } from 'src/engine/core-modules/view/dtos/view-filter.dto'; +import { ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service'; +import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/utils/view-graphql-api-exception.filter'; +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'; + +@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 { + 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 { + return this.viewFilterService.findById(id, workspace.id); + } + + @Mutation(() => ViewFilterDTO) + async createCoreViewFilter( + @Args('input') input: CreateViewFilterInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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 { + return this.viewFilterService.update(id, workspace.id, input); + } + + @Mutation(() => Boolean) + async deleteCoreViewFilter( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedViewFilter = await this.viewFilterService.delete( + id, + workspace.id, + ); + + return isDefined(deletedViewFilter); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view-group.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-group.resolver.ts new file mode 100644 index 0000000000..ca3125f478 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-group.resolver.ts @@ -0,0 +1,74 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewGroupInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-group.input'; +import { UpdateViewGroupInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-group.input'; +import { ViewGroupDTO } from 'src/engine/core-modules/view/dtos/view-group.dto'; +import { ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service'; +import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/utils/view-graphql-api-exception.filter'; +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'; + +@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 { + 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 { + return this.viewGroupService.findById(id, workspace.id); + } + + @Mutation(() => ViewGroupDTO) + async createCoreViewGroup( + @Args('input') input: CreateViewGroupInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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 { + return this.viewGroupService.update(id, workspace.id, input); + } + + @Mutation(() => Boolean) + async deleteCoreViewGroup( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedViewGroup = await this.viewGroupService.delete( + id, + workspace.id, + ); + + return isDefined(deletedViewGroup); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view-sort.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-sort.resolver.ts new file mode 100644 index 0000000000..7bbfc666ad --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view-sort.resolver.ts @@ -0,0 +1,71 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewSortInput } from 'src/engine/core-modules/view/dtos/inputs/create-view-sort.input'; +import { UpdateViewSortInput } from 'src/engine/core-modules/view/dtos/inputs/update-view-sort.input'; +import { ViewSortDTO } from 'src/engine/core-modules/view/dtos/view-sort.dto'; +import { ViewSortService } from 'src/engine/core-modules/view/services/view-sort.service'; +import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/utils/view-graphql-api-exception.filter'; +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'; + +@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 { + 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 { + return this.viewSortService.findById(id, workspace.id); + } + + @Mutation(() => ViewSortDTO) + async createCoreViewSort( + @Args('input') input: CreateViewSortInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + 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 { + return this.viewSortService.update(id, workspace.id, input); + } + + @Mutation(() => Boolean) + async deleteCoreViewSort( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedViewSort = await this.viewSortService.delete(id, workspace.id); + + return isDefined(deletedViewSort); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts new file mode 100644 index 0000000000..8d00fe246b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts @@ -0,0 +1,74 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreateViewInput } from 'src/engine/core-modules/view/dtos/inputs/create-view.input'; +import { UpdateViewInput } from 'src/engine/core-modules/view/dtos/inputs/update-view.input'; +import { ViewDTO } from 'src/engine/core-modules/view/dtos/view.dto'; +import { ViewService } from 'src/engine/core-modules/view/services/view.service'; +import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/utils/view-graphql-api-exception.filter'; +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'; + +@Resolver(() => ViewDTO) +@UseFilters(ViewGraphqlApiExceptionFilter) +@UseGuards(WorkspaceAuthGuard) +export class ViewResolver { + constructor(private readonly viewService: ViewService) {} + + @Query(() => [ViewDTO]) + async getCoreViews( + @AuthWorkspace() workspace: Workspace, + @Args('objectMetadataId', { type: () => String, nullable: true }) + objectMetadataId?: string, + ): Promise { + if (objectMetadataId) { + return this.viewService.findByObjectMetadataId( + workspace.id, + objectMetadataId, + ); + } + + return this.viewService.findByWorkspaceId(workspace.id); + } + + @Query(() => ViewDTO, { nullable: true }) + async getCoreView( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewService.findById(id, workspace.id); + } + + @Mutation(() => ViewDTO) + async createCoreView( + @Args('input') input: CreateViewInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewService.create({ + ...input, + workspaceId: workspace.id, + }); + } + + @Mutation(() => ViewDTO) + async updateCoreView( + @Args('id', { type: () => String }) id: string, + @Args('input') input: UpdateViewInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.viewService.update(id, workspace.id, input); + } + + @Mutation(() => Boolean) + async deleteCoreView( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedView = await this.viewService.delete(id, workspace.id); + + return isDefined(deletedView); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts new file mode 100644 index 0000000000..a340f3740b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts @@ -0,0 +1,297 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { + ViewFieldException, + ViewFieldExceptionCode, + ViewFieldExceptionMessageKey, + generateViewFieldExceptionMessage, + generateViewFieldUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-field.exception'; +import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service'; + +describe('ViewFieldService', () => { + let viewFieldService: ViewFieldService; + let viewFieldRepository: Repository; + + 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 ViewField; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewFieldService, + { + provide: getRepositoryToken(ViewField, 'core'), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + viewFieldService = module.get(ViewFieldService); + viewFieldRepository = module.get>( + getRepositoryToken(ViewField, 'core'), + ); + }); + + 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 create a view field successfully', async () => { + jest.spyOn(viewFieldRepository, 'create').mockReturnValue(mockViewField); + jest.spyOn(viewFieldRepository, 'save').mockResolvedValue(mockViewField); + + const result = await viewFieldService.create(validViewFieldData); + + expect(viewFieldRepository.create).toHaveBeenCalledWith( + validViewFieldData, + ); + expect(viewFieldRepository.save).toHaveBeenCalledWith(mockViewField); + expect(result).toEqual(mockViewField); + }); + + 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, + ), + }, + ), + ); + }); + }); + + 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 }; + + jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField); + jest + .spyOn(viewFieldRepository, 'save') + .mockResolvedValue(updatedViewField); + + 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, + ), + ); + }); + }); + + 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, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter-group.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter-group.service.spec.ts new file mode 100644 index 0000000000..d714a129e9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter-group.service.spec.ts @@ -0,0 +1,332 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; +import { + ViewFilterGroupException, + ViewFilterGroupExceptionCode, + ViewFilterGroupExceptionMessageKey, + generateViewFilterGroupExceptionMessage, + generateViewFilterGroupUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; +import { ViewFilterGroupService } from 'src/engine/core-modules/view/services/view-filter-group.service'; + +describe('ViewFilterGroupService', () => { + let viewFilterGroupService: ViewFilterGroupService; + let viewFilterGroupRepository: Repository; + + 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 ViewFilterGroup; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewFilterGroupService, + { + provide: getRepositoryToken(ViewFilterGroup, 'core'), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + viewFilterGroupService = module.get( + ViewFilterGroupService, + ); + viewFilterGroupRepository = module.get>( + getRepositoryToken(ViewFilterGroup, 'core'), + ); + }); + + 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, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter.service.spec.ts new file mode 100644 index 0000000000..21b793b841 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-filter.service.spec.ts @@ -0,0 +1,313 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { + ViewFilterException, + ViewFilterExceptionCode, + ViewFilterExceptionMessageKey, + generateViewFilterExceptionMessage, + generateViewFilterUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-filter.exception'; +import { ViewFilterService } from 'src/engine/core-modules/view/services/view-filter.service'; + +describe('ViewFilterService', () => { + let viewFilterService: ViewFilterService; + let viewFilterRepository: Repository; + + 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 ViewFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewFilterService, + { + provide: getRepositoryToken(ViewFilter, 'core'), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + viewFilterService = module.get(ViewFilterService); + viewFilterRepository = module.get>( + getRepositoryToken(ViewFilter, 'core'), + ); + }); + + 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, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-group.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-group.service.spec.ts new file mode 100644 index 0000000000..9a90e29b4e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-group.service.spec.ts @@ -0,0 +1,297 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { + ViewGroupException, + ViewGroupExceptionCode, + ViewGroupExceptionMessageKey, + generateViewGroupExceptionMessage, + generateViewGroupUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-group.exception'; +import { ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service'; + +describe('ViewGroupService', () => { + let viewGroupService: ViewGroupService; + let viewGroupRepository: Repository; + + 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 ViewGroup; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewGroupService, + { + provide: getRepositoryToken(ViewGroup, 'core'), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + viewGroupService = module.get(ViewGroupService); + viewGroupRepository = module.get>( + getRepositoryToken(ViewGroup, 'core'), + ); + }); + + 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, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-sort.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-sort.service.spec.ts new file mode 100644 index 0000000000..e34d70d206 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-sort.service.spec.ts @@ -0,0 +1,284 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; +import { + ViewSortException, + ViewSortExceptionCode, + ViewSortExceptionMessageKey, + generateViewSortExceptionMessage, + generateViewSortUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-sort.exception'; +import { ViewSortService } from 'src/engine/core-modules/view/services/view-sort.service'; + +describe('ViewSortService', () => { + let viewSortService: ViewSortService; + let viewSortRepository: Repository; + + 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 ViewSort; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewSortService, + { + provide: getRepositoryToken(ViewSort, 'core'), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + viewSortService = module.get(ViewSortService); + viewSortRepository = module.get>( + getRepositoryToken(ViewSort, 'core'), + ); + }); + + 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, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts new file mode 100644 index 0000000000..52da1fe673 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts @@ -0,0 +1,298 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; +import { + ViewException, + ViewExceptionCode, + ViewExceptionMessageKey, + generateViewExceptionMessage, + generateViewUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view.exception'; +import { ViewService } from 'src/engine/core-modules/view/services/view.service'; + +describe('ViewService', () => { + let viewService: ViewService; + let viewRepository: Repository; + + const mockView = { + id: 'view-id', + name: 'Test View', + objectMetadataId: 'object-id', + workspaceId: 'workspace-id', + type: ViewType.TABLE, + icon: 'test-icon', + position: 0, + isCompact: false, + key: 'INDEX', + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + kanbanAggregateOperation: null, + kanbanAggregateOperationFieldMetadataId: null, + anyFieldFilterValue: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as View; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ViewService, + { + provide: getRepositoryToken(View, 'core'), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + viewService = module.get(ViewService); + viewRepository = module.get>( + getRepositoryToken(View, 'core'), + ); + }); + + it('should be defined', () => { + expect(viewService).toBeDefined(); + }); + + describe('findByWorkspaceId', () => { + it('should return views for a workspace', async () => { + const workspaceId = 'workspace-id'; + const expectedViews = [mockView]; + + jest.spyOn(viewRepository, 'find').mockResolvedValue(expectedViews); + + const result = await viewService.findByWorkspaceId(workspaceId); + + expect(viewRepository.find).toHaveBeenCalledWith({ + where: { + workspaceId, + deletedAt: expect.anything(), + }, + order: { position: 'ASC' }, + relations: [ + 'workspace', + 'viewFields', + 'viewFilters', + 'viewSorts', + 'viewGroups', + 'viewFilterGroups', + ], + }); + expect(result).toEqual(expectedViews); + }); + }); + + describe('findByObjectMetadataId', () => { + it('should return views for an object metadata id', async () => { + const workspaceId = 'workspace-id'; + const objectMetadataId = 'object-id'; + const expectedViews = [mockView]; + + jest.spyOn(viewRepository, 'find').mockResolvedValue(expectedViews); + + const result = await viewService.findByObjectMetadataId( + workspaceId, + objectMetadataId, + ); + + expect(viewRepository.find).toHaveBeenCalledWith({ + where: { + workspaceId, + objectMetadataId, + deletedAt: expect.anything(), + }, + order: { position: 'ASC' }, + relations: [ + 'workspace', + 'viewFields', + 'viewFilters', + 'viewSorts', + 'viewGroups', + 'viewFilterGroups', + ], + }); + expect(result).toEqual(expectedViews); + }); + }); + + describe('findById', () => { + it('should return a view by id', async () => { + const id = 'view-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(viewRepository, 'findOne').mockResolvedValue(mockView); + + const result = await viewService.findById(id, workspaceId); + + expect(viewRepository.findOne).toHaveBeenCalledWith({ + where: { + id, + workspaceId, + deletedAt: expect.anything(), + }, + relations: [ + 'workspace', + 'viewFields', + 'viewFilters', + 'viewSorts', + 'viewGroups', + 'viewFilterGroups', + ], + }); + expect(result).toEqual(mockView); + }); + + it('should return null when view is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(viewRepository, 'findOne').mockResolvedValue(null); + + const result = await viewService.findById(id, workspaceId); + + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + const validViewData = { + name: 'Test View', + workspaceId: 'workspace-id', + objectMetadataId: 'object-id', + type: ViewType.TABLE, + icon: 'test-icon', + }; + + it('should create a view successfully', async () => { + jest.spyOn(viewRepository, 'create').mockReturnValue(mockView); + jest.spyOn(viewRepository, 'save').mockResolvedValue(mockView); + + const result = await viewService.create(validViewData); + + expect(viewRepository.create).toHaveBeenCalledWith(validViewData); + expect(viewRepository.save).toHaveBeenCalledWith(mockView); + expect(result).toEqual(mockView); + }); + + it('should throw exception when workspaceId is missing', async () => { + const invalidData = { ...validViewData, workspaceId: undefined }; + + await expect(viewService.create(invalidData)).rejects.toThrow( + new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, + ), + ViewExceptionCode.INVALID_VIEW_DATA, + { + userFriendlyMessage: generateViewUserFriendlyExceptionMessage( + ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, + ), + }, + ), + ); + }); + + it('should throw exception when objectMetadataId is missing', async () => { + const invalidData = { ...validViewData, objectMetadataId: undefined }; + + await expect(viewService.create(invalidData)).rejects.toThrow( + new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, + ), + ViewExceptionCode.INVALID_VIEW_DATA, + { + userFriendlyMessage: generateViewUserFriendlyExceptionMessage( + ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, + ), + }, + ), + ); + }); + }); + + describe('update', () => { + it('should update a view successfully', async () => { + const id = 'view-id'; + const workspaceId = 'workspace-id'; + const updateData = { name: 'Updated View' }; + const updatedView = { ...mockView, ...updateData }; + + jest.spyOn(viewService, 'findById').mockResolvedValue(mockView); + jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView); + + const result = await viewService.update(id, workspaceId, updateData); + + expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId); + expect(viewRepository.save).toHaveBeenCalledWith({ + id, + ...updateData, + }); + expect(result).toEqual({ ...mockView, ...updatedView }); + }); + + it('should throw exception when view is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + const updateData = { name: 'Updated View' }; + + jest.spyOn(viewService, 'findById').mockResolvedValue(null); + + await expect( + viewService.update(id, workspaceId, updateData), + ).rejects.toThrow( + new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + id, + ), + ViewExceptionCode.VIEW_NOT_FOUND, + ), + ); + }); + }); + + describe('delete', () => { + it('should delete a view successfully', async () => { + const id = 'view-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(viewService, 'findById').mockResolvedValue(mockView); + jest.spyOn(viewRepository, 'softDelete').mockResolvedValue({} as any); + + const result = await viewService.delete(id, workspaceId); + + expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId); + expect(viewRepository.softDelete).toHaveBeenCalledWith(id); + expect(result).toEqual(mockView); + }); + + it('should throw exception when view is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(viewService, 'findById').mockResolvedValue(null); + + await expect(viewService.delete(id, workspaceId)).rejects.toThrow( + new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + id, + ), + ViewExceptionCode.VIEW_NOT_FOUND, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts new file mode 100644 index 0000000000..97a1439576 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { + ViewFieldException, + ViewFieldExceptionCode, + ViewFieldExceptionMessageKey, + generateViewFieldExceptionMessage, + generateViewFieldUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-field.exception'; + +@Injectable() +export class ViewFieldService { + constructor( + @InjectRepository(ViewField, 'core') + private readonly viewFieldRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.viewFieldRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: ['workspace', 'view'], + }); + } + + async findByViewId( + workspaceId: string, + viewId: string, + ): Promise { + return this.viewFieldRepository.find({ + where: { + workspaceId, + viewId, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: ['workspace', 'view'], + }); + } + + async findById(id: string, workspaceId: string): Promise { + const viewField = await this.viewFieldRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: ['workspace', 'view'], + }); + + return viewField || null; + } + + async create(viewFieldData: Partial): Promise { + 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, + ), + }, + ); + } + + if (!isDefined(viewFieldData.fieldMetadataId)) { + throw new ViewFieldException( + generateViewFieldExceptionMessage( + ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED, + ), + ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA, + { + userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage( + ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED, + ), + }, + ); + } + + const viewField = this.viewFieldRepository.create(viewFieldData); + + return this.viewFieldRepository.save(viewField); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + 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 updatedViewField = await this.viewFieldRepository.save({ + id, + ...updateData, + }); + + return { ...existingViewField, ...updatedViewField }; + } + + async delete(id: string, workspaceId: string): Promise { + 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); + + return viewField; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view-filter-group.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view-filter-group.service.ts new file mode 100644 index 0000000000..4a56a643c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/view-filter-group.service.ts @@ -0,0 +1,164 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { + ViewFilterGroupException, + ViewFilterGroupExceptionCode, + ViewFilterGroupExceptionMessageKey, + generateViewFilterGroupExceptionMessage, + generateViewFilterGroupUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; + +@Injectable() +export class ViewFilterGroupService { + constructor( + @InjectRepository(ViewFilterGroup, 'core') + private readonly viewFilterGroupRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.viewFilterGroupRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + order: { positionInViewFilterGroup: 'ASC' }, + relations: [ + 'workspace', + 'view', + 'viewFilters', + 'parentViewFilterGroup', + 'childViewFilterGroups', + ], + }); + } + + async findByViewId( + workspaceId: string, + viewId: string, + ): Promise { + 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 { + const viewFilterGroup = await this.viewFilterGroupRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: [ + 'workspace', + 'view', + 'viewFilters', + 'parentViewFilterGroup', + 'childViewFilterGroups', + ], + }); + + return viewFilterGroup || null; + } + + async create( + viewFilterGroupData: Partial, + ): Promise { + 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); + + return this.viewFilterGroupRepository.save(viewFilterGroup); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + 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, + }); + + return { ...existingViewFilterGroup, ...updatedViewFilterGroup }; + } + + async delete(id: string, workspaceId: string): Promise { + 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; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view-filter.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view-filter.service.ts new file mode 100644 index 0000000000..dd3378be77 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/view-filter.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { + ViewFilterException, + ViewFilterExceptionCode, + ViewFilterExceptionMessageKey, + generateViewFilterExceptionMessage, + generateViewFilterUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-filter.exception'; + +@Injectable() +export class ViewFilterService { + constructor( + @InjectRepository(ViewFilter, 'core') + private readonly viewFilterRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.viewFilterRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + order: { positionInViewFilterGroup: 'ASC' }, + relations: ['workspace', 'view', 'viewFilterGroup'], + }); + } + + async findByViewId( + workspaceId: string, + viewId: string, + ): Promise { + return this.viewFilterRepository.find({ + where: { + workspaceId, + viewId, + deletedAt: IsNull(), + }, + order: { positionInViewFilterGroup: 'ASC' }, + relations: ['workspace', 'view', 'viewFilterGroup'], + }); + } + + async findById(id: string, workspaceId: string): Promise { + const viewFilter = await this.viewFilterRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: ['workspace', 'view', 'viewFilterGroup'], + }); + + return viewFilter || null; + } + + async create(viewFilterData: Partial): Promise { + 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); + + return this.viewFilterRepository.save(viewFilter); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + 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, + }); + + return { ...existingViewFilter, ...updatedViewFilter }; + } + + async delete(id: string, workspaceId: string): Promise { + 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); + + return viewFilter; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view-group.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view-group.service.ts new file mode 100644 index 0000000000..d6109628b6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/view-group.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { + ViewGroupException, + ViewGroupExceptionCode, + ViewGroupExceptionMessageKey, + generateViewGroupExceptionMessage, + generateViewGroupUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-group.exception'; + +@Injectable() +export class ViewGroupService { + constructor( + @InjectRepository(ViewGroup, 'core') + private readonly viewGroupRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.viewGroupRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: ['workspace', 'view'], + }); + } + + async findByViewId( + workspaceId: string, + viewId: string, + ): Promise { + return this.viewGroupRepository.find({ + where: { + workspaceId, + viewId, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: ['workspace', 'view'], + }); + } + + async findById(id: string, workspaceId: string): Promise { + const viewGroup = await this.viewGroupRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: ['workspace', 'view'], + }); + + return viewGroup || null; + } + + async create(viewGroupData: Partial): Promise { + 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); + + return this.viewGroupRepository.save(viewGroup); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + 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, + }); + + return { ...existingViewGroup, ...updatedViewGroup }; + } + + async delete(id: string, workspaceId: string): Promise { + 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); + + return viewGroup; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view-sort.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view-sort.service.ts new file mode 100644 index 0000000000..a150665762 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/view-sort.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { + ViewSortException, + ViewSortExceptionCode, + ViewSortExceptionMessageKey, + generateViewSortExceptionMessage, + generateViewSortUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-sort.exception'; + +@Injectable() +export class ViewSortService { + constructor( + @InjectRepository(ViewSort, 'core') + private readonly viewSortRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.viewSortRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + relations: ['workspace', 'view'], + }); + } + + async findByViewId(workspaceId: string, viewId: string): Promise { + return this.viewSortRepository.find({ + where: { + workspaceId, + viewId, + deletedAt: IsNull(), + }, + relations: ['workspace', 'view'], + }); + } + + async findById(id: string, workspaceId: string): Promise { + const viewSort = await this.viewSortRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: ['workspace', 'view'], + }); + + return viewSort || null; + } + + async create(viewSortData: Partial): Promise { + 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); + + return this.viewSortRepository.save(viewSort); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + 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, + }); + + return { ...existingViewSort, ...updatedViewSort }; + } + + async delete(id: string, workspaceId: string): Promise { + 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); + + return viewSort; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts new file mode 100644 index 0000000000..4c9b9168a6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts @@ -0,0 +1,159 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { + ViewException, + ViewExceptionCode, + ViewExceptionMessageKey, + generateViewExceptionMessage, + generateViewUserFriendlyExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view.exception'; + +@Injectable() +export class ViewService { + constructor( + @InjectRepository(View, 'core') + private readonly viewRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.viewRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: [ + 'workspace', + 'viewFields', + 'viewFilters', + 'viewSorts', + 'viewGroups', + 'viewFilterGroups', + ], + }); + } + + async findByObjectMetadataId( + workspaceId: string, + objectMetadataId: string, + ): Promise { + return this.viewRepository.find({ + where: { + workspaceId, + objectMetadataId, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: [ + 'workspace', + 'viewFields', + 'viewFilters', + 'viewSorts', + 'viewGroups', + 'viewFilterGroups', + ], + }); + } + + async findById(id: string, workspaceId: string): Promise { + const view = await this.viewRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: [ + 'workspace', + 'viewFields', + 'viewFilters', + 'viewSorts', + 'viewGroups', + 'viewFilterGroups', + ], + }); + + return view || null; + } + + async create(viewData: Partial): Promise { + if (!isDefined(viewData.workspaceId)) { + throw new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, + ), + ViewExceptionCode.INVALID_VIEW_DATA, + { + userFriendlyMessage: generateViewUserFriendlyExceptionMessage( + ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, + ), + }, + ); + } + + if (!isDefined(viewData.objectMetadataId)) { + throw new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, + ), + ViewExceptionCode.INVALID_VIEW_DATA, + { + userFriendlyMessage: generateViewUserFriendlyExceptionMessage( + ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, + ), + }, + ); + } + + const view = this.viewRepository.create(viewData); + + return this.viewRepository.save(view); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + const existingView = await this.findById(id, workspaceId); + + if (!isDefined(existingView)) { + throw new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + id, + ), + ViewExceptionCode.VIEW_NOT_FOUND, + ); + } + + const updatedView = await this.viewRepository.save({ + id, + ...updateData, + }); + + return { ...existingView, ...updatedView }; + } + + async delete(id: string, workspaceId: string): Promise { + const view = await this.findById(id, workspaceId); + + if (!isDefined(view)) { + throw new ViewException( + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + id, + ), + ViewExceptionCode.VIEW_NOT_FOUND, + ); + } + + await this.viewRepository.softDelete(id); + + return view; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/types/view-filter-value.type.ts b/packages/twenty-server/src/engine/core-modules/view/types/view-filter-value.type.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/view/types/view-filter-value.type.ts rename to packages/twenty-server/src/engine/core-modules/view/types/view-filter-value.type.ts diff --git a/packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000..9ac0eafc88 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception-handler.util.ts @@ -0,0 +1,128 @@ +import { + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + ViewFieldException, + ViewFieldExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-field.exception'; +import { + ViewFilterGroupException, + ViewFilterGroupExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; +import { + ViewFilterException, + ViewFilterExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-filter.exception'; +import { + ViewGroupException, + ViewGroupExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-group.exception'; +import { + ViewSortException, + ViewSortExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view-sort.exception'; +import { + ViewException, + ViewExceptionCode, +} from 'src/engine/core-modules/view/exceptions/view.exception'; + +export const viewGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof ViewException) { + switch (error.code) { + case ViewExceptionCode.VIEW_NOT_FOUND: + throw new NotFoundError(error.message); + case ViewExceptionCode.INVALID_VIEW_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + if (error instanceof ViewFieldException) { + switch (error.code) { + case ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND: + throw new NotFoundError(error.message); + case ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + if (error instanceof ViewFilterException) { + switch (error.code) { + case ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND: + throw new NotFoundError(error.message); + case ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + if (error instanceof ViewFilterGroupException) { + switch (error.code) { + case ViewFilterGroupExceptionCode.VIEW_FILTER_GROUP_NOT_FOUND: + throw new NotFoundError(error.message); + case ViewFilterGroupExceptionCode.INVALID_VIEW_FILTER_GROUP_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + if (error instanceof ViewGroupException) { + switch (error.code) { + case ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND: + throw new NotFoundError(error.message); + case ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + if (error instanceof ViewSortException) { + switch (error.code) { + case ViewSortExceptionCode.VIEW_SORT_NOT_FOUND: + throw new NotFoundError(error.message); + case ViewSortExceptionCode.INVALID_VIEW_SORT_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + const _exhaustiveCheck: never = error.code; + + throw error; + } + } + } + + throw error; +}; diff --git a/packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..54e39841ab --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/utils/view-graphql-api-exception.filter.ts @@ -0,0 +1,31 @@ +import { Catch, ExceptionFilter } from '@nestjs/common'; + +import { ViewFieldException } from 'src/engine/core-modules/view/exceptions/view-field.exception'; +import { ViewFilterGroupException } from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; +import { ViewFilterException } from 'src/engine/core-modules/view/exceptions/view-filter.exception'; +import { ViewGroupException } from 'src/engine/core-modules/view/exceptions/view-group.exception'; +import { ViewSortException } from 'src/engine/core-modules/view/exceptions/view-sort.exception'; +import { ViewException } from 'src/engine/core-modules/view/exceptions/view.exception'; +import { viewGraphqlApiExceptionHandler } from 'src/engine/core-modules/view/utils/view-graphql-api-exception-handler.util'; + +@Catch( + ViewException, + ViewFieldException, + ViewFilterException, + ViewFilterGroupException, + ViewGroupException, + ViewSortException, +) +export class ViewGraphqlApiExceptionFilter implements ExceptionFilter { + catch( + exception: + | ViewException + | ViewFieldException + | ViewFilterException + | ViewFilterGroupException + | ViewGroupException + | ViewSortException, + ) { + return viewGraphqlApiExceptionHandler(exception); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/view.module.ts b/packages/twenty-server/src/engine/core-modules/view/view.module.ts new file mode 100644 index 0000000000..4bce9f0954 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/view/view.module.ts @@ -0,0 +1,71 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; +import { ViewFieldController } from 'src/engine/core-modules/view/controllers/view-field.controller'; +import { ViewFilterGroupController } from 'src/engine/core-modules/view/controllers/view-filter-group.controller'; +import { ViewFilterController } from 'src/engine/core-modules/view/controllers/view-filter.controller'; +import { ViewGroupController } from 'src/engine/core-modules/view/controllers/view-group.controller'; +import { ViewSortController } from 'src/engine/core-modules/view/controllers/view-sort.controller'; +import { ViewController } from 'src/engine/core-modules/view/controllers/view.controller'; +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewFieldResolver } from 'src/engine/core-modules/view/resolvers/view-field.resolver'; +import { ViewFilterGroupResolver } from 'src/engine/core-modules/view/resolvers/view-filter-group.resolver'; +import { ViewFilterResolver } from 'src/engine/core-modules/view/resolvers/view-filter.resolver'; +import { ViewGroupResolver } from 'src/engine/core-modules/view/resolvers/view-group.resolver'; +import { ViewSortResolver } from 'src/engine/core-modules/view/resolvers/view-sort.resolver'; +import { ViewResolver } from 'src/engine/core-modules/view/resolvers/view.resolver'; +import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service'; +import { ViewFilterGroupService } from 'src/engine/core-modules/view/services/view-filter-group.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 { ViewSortService } from 'src/engine/core-modules/view/services/view-sort.service'; +import { ViewService } from 'src/engine/core-modules/view/services/view.service'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature( + [View, ViewField, ViewFilter, ViewFilterGroup, ViewGroup, ViewSort], + 'core', + ), + AuthModule, + WorkspaceCacheStorageModule, + ], + controllers: [ + ViewController, + ViewFieldController, + ViewFilterController, + ViewFilterGroupController, + ViewGroupController, + ViewSortController, + ], + providers: [ + ViewService, + ViewFieldService, + ViewFilterService, + ViewFilterGroupService, + ViewGroupService, + ViewSortService, + ViewResolver, + ViewFieldResolver, + ViewFilterResolver, + ViewFilterGroupResolver, + ViewGroupResolver, + ViewSortResolver, + ], + exports: [ + ViewService, + ViewFieldService, + ViewFilterService, + ViewFilterGroupService, + ViewGroupService, + ViewSortService, + ], +}) +export class CoreViewModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index b6c8d7b5aa..6019f5e2ef 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -135,6 +135,7 @@ describe('WorkspaceEntityManager', () => { IS_RELATION_CONNECT_ENABLED: false, IS_FIELDS_PERMISSIONS_ENABLED: false, IS_CORE_VIEW_SYNCING_ENABLED: false, + IS_CORE_VIEW_ENABLED: false, IS_TWO_FACTOR_AUTHENTICATION_ENABLED: false, IS_WORKSPACE_MIGRATION_V2_ENABLED: false, IS_API_KEY_ROLES_ENABLED: false, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 821424a597..eee0f64c5b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -60,6 +60,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_CORE_VIEW_ENABLED, + workspaceId: workspaceId, + value: false, + }, { key: FeatureFlagKey.IS_TWO_FACTOR_AUTHENTICATION_ENABLED, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts index 97d41ee781..975f439882 100644 --- a/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts +++ b/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts @@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; -import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity'; +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity'; @Injectable() diff --git a/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts index b0eb640ca2..8e43f629d1 100644 --- a/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts +++ b/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts @@ -5,8 +5,8 @@ import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; -import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view/enums/view-filter-group-logical-operator'; -import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity'; @Injectable() diff --git a/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts index b3832e1e92..03e7f6f132 100644 --- a/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts +++ b/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts @@ -1,12 +1,14 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ViewFilterOperand as SharedViewFilterOperand } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; -import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity'; +import { convertViewFilterOperandToCoreOperand } from 'src/modules/view/utils/convert-view-filter-operand-to-core-operand.util'; import { transformViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/transform-view-filter-workspace-value-to-core-value'; @Injectable() @@ -33,6 +35,10 @@ export class ViewFilterSyncService { updateData[key] = transformViewFilterWorkspaceValueToCoreValue( diffValue.after, ); + } else if (key === 'operand' && diffValue.after) { + updateData[key] = convertViewFilterOperandToCoreOperand( + diffValue.after as SharedViewFilterOperand, + ); } else { updateData[key] = diffValue.after; } @@ -54,7 +60,9 @@ export class ViewFilterSyncService { id: workspaceViewFilter.id, fieldMetadataId: workspaceViewFilter.fieldMetadataId, viewId: workspaceViewFilter.viewId, - operand: workspaceViewFilter.operand, + operand: convertViewFilterOperandToCoreOperand( + workspaceViewFilter.operand as SharedViewFilterOperand, + ), value: transformViewFilterWorkspaceValueToCoreValue( workspaceViewFilter.value, ), diff --git a/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts index fbc888d65f..055030a2d1 100644 --- a/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts +++ b/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts @@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; -import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; @Injectable() diff --git a/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts index d582d8a86e..1593c82c76 100644 --- a/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts +++ b/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts @@ -5,8 +5,8 @@ import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; -import { ViewSortDirection } from 'src/engine/metadata-modules/view/enums/view-sort-direction'; -import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; @Injectable() diff --git a/packages/twenty-server/src/modules/view/services/view-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-sync.service.ts index a0845df41d..07dcdee2e4 100644 --- a/packages/twenty-server/src/modules/view/services/view-sync.service.ts +++ b/packages/twenty-server/src/modules/view/services/view-sync.service.ts @@ -5,8 +5,9 @@ import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; -import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; @Injectable() @@ -34,6 +35,9 @@ export class ViewSyncService { diffValue.after === 'SIDE_PANEL' ? ViewOpenRecordIn.SIDE_PANEL : ViewOpenRecordIn.RECORD_PAGE; + } else if (key === 'type') { + updateData[key] = + diffValue.after === 'table' ? ViewType.TABLE : ViewType.KANBAN; } else { updateData[key] = diffValue.after; } @@ -51,7 +55,7 @@ export class ViewSyncService { id: workspaceView.id, name: workspaceView.name, objectMetadataId: workspaceView.objectMetadataId, - type: workspaceView.type, + type: workspaceView.type === 'table' ? ViewType.TABLE : ViewType.KANBAN, key: workspaceView.key, icon: workspaceView.icon, position: workspaceView.position, diff --git a/packages/twenty-server/src/modules/view/utils/convert-view-filter-operand-to-core-operand.util.ts b/packages/twenty-server/src/modules/view/utils/convert-view-filter-operand-to-core-operand.util.ts new file mode 100644 index 0000000000..3607a34ad9 --- /dev/null +++ b/packages/twenty-server/src/modules/view/utils/convert-view-filter-operand-to-core-operand.util.ts @@ -0,0 +1,30 @@ +import { ViewFilterOperand as SharedViewFilterOperand } from 'twenty-shared/types'; + +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; + +const operandMapping: Record = { + [SharedViewFilterOperand.Is]: ViewFilterOperand.IS, + [SharedViewFilterOperand.IsNotNull]: ViewFilterOperand.IS_NOT_NULL, + [SharedViewFilterOperand.IsNot]: ViewFilterOperand.IS_NOT, + [SharedViewFilterOperand.LessThanOrEqual]: + ViewFilterOperand.LESS_THAN_OR_EQUAL, + [SharedViewFilterOperand.GreaterThanOrEqual]: + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + [SharedViewFilterOperand.IsBefore]: ViewFilterOperand.IS_BEFORE, + [SharedViewFilterOperand.IsAfter]: ViewFilterOperand.IS_AFTER, + [SharedViewFilterOperand.Contains]: ViewFilterOperand.CONTAINS, + [SharedViewFilterOperand.DoesNotContain]: ViewFilterOperand.DOES_NOT_CONTAIN, + [SharedViewFilterOperand.IsEmpty]: ViewFilterOperand.IS_EMPTY, + [SharedViewFilterOperand.IsNotEmpty]: ViewFilterOperand.IS_NOT_EMPTY, + [SharedViewFilterOperand.IsRelative]: ViewFilterOperand.IS_RELATIVE, + [SharedViewFilterOperand.IsInPast]: ViewFilterOperand.IS_IN_PAST, + [SharedViewFilterOperand.IsInFuture]: ViewFilterOperand.IS_IN_FUTURE, + [SharedViewFilterOperand.IsToday]: ViewFilterOperand.IS_TODAY, + [SharedViewFilterOperand.VectorSearch]: ViewFilterOperand.VECTOR_SEARCH, +}; + +export const convertViewFilterOperandToCoreOperand = ( + sharedOperand: SharedViewFilterOperand, +): ViewFilterOperand => { + return operandMapping[sharedOperand]; +}; diff --git a/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts b/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts index 4735665c6f..1956e66a83 100644 --- a/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts +++ b/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts @@ -1,4 +1,4 @@ -import { ViewFilterValue } from 'src/engine/metadata-modules/view/types/view-filter-value.type'; +import { ViewFilterValue } from 'src/engine/core-modules/view/types/view-filter-value.type'; export const transformViewFilterWorkspaceValueToCoreValue = ( value: string, diff --git a/packages/twenty-server/src/modules/view/view.module.ts b/packages/twenty-server/src/modules/view/view.module.ts index e40c21b17d..e88af65224 100644 --- a/packages/twenty-server/src/modules/view/view.module.ts +++ b/packages/twenty-server/src/modules/view/view.module.ts @@ -2,12 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity'; -import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity'; -import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity'; -import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity'; -import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity'; -import { View } from 'src/engine/metadata-modules/view/view.entity'; +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; import { ViewFieldListener } from 'src/modules/view/listeners/view-field.listener'; import { ViewFilterGroupListener } from 'src/modules/view/listeners/view-filter-group.listener'; import { ViewFilterListener } from 'src/modules/view/listeners/view-filter.listener'; @@ -31,6 +31,7 @@ import { ViewService } from 'src/modules/view/services/view.service'; ), FeatureFlagModule, ], + providers: [ ViewService, ViewDeleteOnePreQueryHook, diff --git a/packages/twenty-server/test/integration/constants/test-view-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-view-ids.constants.ts new file mode 100644 index 0000000000..d77bb590b5 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-view-ids.constants.ts @@ -0,0 +1,21 @@ +export const TEST_VIEW_1_ID = '20202020-05e2-439a-a768-d10e2945759e'; +export const TEST_VIEW_2_ID = '20202020-d172-433b-ba1b-b03ad2560f09'; +export const TEST_VIEW_3_ID = '20202020-2bbd-4e25-b141-24f11da50e0a'; + +export const TEST_OBJECT_METADATA_1_ID = '20202020-1436-4a52-b386-74a0142fb1d1'; +export const TEST_OBJECT_METADATA_2_ID = '20202020-887f-4bc0-9011-2a81a3e852c3'; + +export const TEST_FIELD_METADATA_1_ID = '20202020-ac79-494f-a0a5-e456c72dcc6f'; +export const TEST_FIELD_METADATA_2_ID = '20202020-be78-4d14-a413-ea4d8562e6f0'; + +export const TEST_NOT_EXISTING_VIEW_ID = '20202020-ce26-4249-93a0-b71d405d3775'; +export const TEST_NOT_EXISTING_VIEW_FIELD_ID = + '20202020-6be8-40ec-82fa-5c9ae1930915'; +export const TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID = + '20202020-e214-44fa-a39a-d81447b2c44f'; +export const TEST_NOT_EXISTING_VIEW_FILTER_ID = + '20202020-034a-433d-b6e6-5ee7aaf5aaa6'; +export const TEST_NOT_EXISTING_VIEW_SORT_ID = + '20202020-e0dd-49d6-94f9-a23416fc5a50'; +export const TEST_NOT_EXISTING_VIEW_GROUP_ID = + '20202020-d1df-43be-9cf7-187dbffa08d7'; diff --git a/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts new file mode 100644 index 0000000000..15e03b1fe2 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts @@ -0,0 +1,70 @@ +export const VIEW_GQL_FIELDS = ` + id + name + objectMetadataId + type + key + icon + position + isCompact + openRecordIn + workspaceId + anyFieldFilterValue + createdAt + updatedAt + deletedAt +`; + +export const VIEW_FIELD_GQL_FIELDS = ` + id + fieldMetadataId + position + isVisible + size + viewId + createdAt + updatedAt + deletedAt +`; + +export const VIEW_SORT_GQL_FIELDS = ` + id + fieldMetadataId + direction + viewId + createdAt + updatedAt + deletedAt +`; + +export const VIEW_FILTER_GQL_FIELDS = ` + id + fieldMetadataId + operand + value + viewId + createdAt + updatedAt + deletedAt +`; + +export const VIEW_GROUP_GQL_FIELDS = ` + id + fieldMetadataId + fieldValue + isVisible + position + viewId + createdAt + updatedAt +`; + +export const VIEW_FILTER_GROUP_GQL_FIELDS = ` + id + logicalOperator + parentViewFilterGroupId + viewId + createdAt + updatedAt + deletedAt +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-field-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-field-resolver.integration-spec.ts new file mode 100644 index 0000000000..59731cff51 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-field-resolver.integration-spec.ts @@ -0,0 +1,213 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_FIELD_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { createViewFieldOperationFactory } from 'test/integration/graphql/utils/create-view-field-operation-factory.util'; +import { deleteViewFieldOperationFactory } from 'test/integration/graphql/utils/delete-view-field-operation-factory.util'; +import { findViewFieldsOperationFactory } from 'test/integration/graphql/utils/find-view-fields-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateViewFieldOperationFactory } from 'test/integration/graphql/utils/update-view-field-operation-factory.util'; +import { + createViewFieldData, + updateViewFieldData, +} from 'test/integration/graphql/utils/view-data-factory.util'; +import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util'; +import { + assertViewFieldStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + generateViewFieldExceptionMessage, + ViewFieldExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-field.exception'; + +describe('View Field Resolver', () => { + let testViewId: string; + + beforeEach(async () => { + await cleanupViewRecords(); + + const view = await createTestViewWithGraphQL({ + name: 'Test View for Fields', + }); + + testViewId = view.id; + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('getCoreViewFields', () => { + it('should return empty array when no view fields exist', async () => { + const operation = findViewFieldsOperationFactory({ viewId: testViewId }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFields).toEqual([]); + }); + + it('should return view fields for a specific view', async () => { + const fieldData = createViewFieldData(testViewId, { + position: 0, + isVisible: true, + size: 150, + }); + const createOperation = createViewFieldOperationFactory({ + data: fieldData, + }); + + await makeGraphqlAPIRequest(createOperation); + + const getOperation = findViewFieldsOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFields).toHaveLength(1); + assertViewFieldStructure(response.body.data.getCoreViewFields[0], { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + position: 0, + isVisible: true, + size: 150, + viewId: testViewId, + }); + }); + }); + + describe('createCoreViewField', () => { + it('should create a new view field', async () => { + const fieldData = createViewFieldData(testViewId, { + position: 1, + isVisible: true, + size: 200, + }); + + const operation = createViewFieldOperationFactory({ data: fieldData }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFieldStructure(response.body.data.createCoreViewField, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + position: 1, + isVisible: true, + size: 200, + viewId: testViewId, + }); + }); + + it('should create a hidden view field', async () => { + const fieldData = { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + position: 2, + isVisible: false, + size: 100, + viewId: testViewId, + }; + + const operation = createViewFieldOperationFactory({ data: fieldData }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFieldStructure(response.body.data.createCoreViewField, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + position: 2, + isVisible: false, + size: 100, + viewId: testViewId, + }); + }); + }); + + describe('updateCoreViewField', () => { + it('should update an existing view field', async () => { + const fieldData = createViewFieldData(testViewId, { + position: 0, + isVisible: true, + size: 150, + }); + const createOperation = createViewFieldOperationFactory({ + data: fieldData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const viewField = createResponse.body.data.createCoreViewField; + + const updateInput = updateViewFieldData({ + position: 5, + isVisible: false, + size: 300, + }); + const updateOperation = updateViewFieldOperationFactory({ + viewFieldId: viewField.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(updateOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.updateCoreViewField).toMatchObject({ + id: viewField.id, + position: 5, + isVisible: false, + size: 300, + }); + }); + + it('should throw an error when updating non-existent view field', async () => { + const operation = updateViewFieldOperationFactory({ + viewFieldId: TEST_NOT_EXISTING_VIEW_FIELD_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewFieldExceptionMessage( + ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FIELD_ID, + ), + ); + }); + }); + + describe('deleteCoreViewField', () => { + it('should delete an existing view field', async () => { + const fieldData = createViewFieldData(testViewId); + const createOperation = createViewFieldOperationFactory({ + data: fieldData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const viewField = createResponse.body.data.createCoreViewField; + + const deleteOperation = deleteViewFieldOperationFactory({ + viewFieldId: viewField.id, + }); + const response = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.deleteCoreViewField).toBe(true); + }); + + it('should throw an error when deleting non-existent view field', async () => { + const operation = deleteViewFieldOperationFactory({ + viewFieldId: TEST_NOT_EXISTING_VIEW_FIELD_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewFieldExceptionMessage( + ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FIELD_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-filter-group-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-filter-group-resolver.integration-spec.ts new file mode 100644 index 0000000000..278c08f391 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-filter-group-resolver.integration-spec.ts @@ -0,0 +1,404 @@ +import { TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID } from 'test/integration/constants/test-view-ids.constants'; +import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util'; +import { deleteViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/delete-view-filter-group-operation-factory.util'; +import { findViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/find-view-filter-group-operation-factory.util'; +import { findViewFilterGroupsOperationFactory } from 'test/integration/graphql/utils/find-view-filter-groups-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/update-view-filter-group-operation-factory.util'; +import { + createViewFilterGroupData, + updateViewFilterGroupData, +} from 'test/integration/graphql/utils/view-data-factory.util'; +import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util'; +import { + assertViewFilterGroupStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; +import { + generateViewFilterGroupExceptionMessage, + ViewFilterGroupExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; + +describe('View Filter Group Resolver', () => { + let testViewId: string; + + beforeEach(async () => { + await cleanupViewRecords(); + + const view = await createTestViewWithGraphQL({ + name: 'Test View for Filter Groups', + }); + + testViewId = view.id; + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('getCoreViewFilterGroups', () => { + it('should return empty array when no view filter groups exist', async () => { + const operation = findViewFilterGroupsOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilterGroups).toEqual([]); + }); + + it('should return all filter groups for workspace when no viewId provided', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const createOperation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + + await makeGraphqlAPIRequest(createOperation); + + const getOperation = findViewFilterGroupsOperationFactory(); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilterGroups).toHaveLength(1); + expect(response.body.data.getCoreViewFilterGroups[0]).toMatchObject({ + logicalOperator: ViewFilterGroupLogicalOperator.AND, + viewId: testViewId, + }); + }); + + it('should return view filter groups for a specific view', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + const createOperation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + + await makeGraphqlAPIRequest(createOperation); + + const getOperation = findViewFilterGroupsOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilterGroups).toHaveLength(1); + assertViewFilterGroupStructure( + response.body.data.getCoreViewFilterGroups[0], + { + logicalOperator: ViewFilterGroupLogicalOperator.OR, + viewId: testViewId, + }, + ); + }); + + it('should return nested filter groups with parent relationships', async () => { + const parentData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const parentOperation = createViewFilterGroupOperationFactory({ + data: parentData, + }); + const parentResponse = await makeGraphqlAPIRequest(parentOperation); + const parentId = parentResponse.body.data.createCoreViewFilterGroup.id; + + const childData = createViewFilterGroupData(testViewId, { + parentViewFilterGroupId: parentId, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + const childOperation = createViewFilterGroupOperationFactory({ + data: childData, + }); + + await makeGraphqlAPIRequest(childOperation); + + const getOperation = findViewFilterGroupsOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilterGroups).toHaveLength(2); + + const parentGroup = response.body.data.getCoreViewFilterGroups.find( + (group: any) => group.parentViewFilterGroupId === null, + ); + const childGroup = response.body.data.getCoreViewFilterGroups.find( + (group: any) => group.parentViewFilterGroupId !== null, + ); + + assertViewFilterGroupStructure(parentGroup, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + parentViewFilterGroupId: null, + }); + + assertViewFilterGroupStructure(childGroup, { + parentViewFilterGroupId: parentId, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + }); + }); + + describe('getCoreViewFilterGroup', () => { + it('should return null when filter group does not exist', async () => { + const operation = findViewFilterGroupOperationFactory({ + viewFilterGroupId: TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilterGroup).toBeNull(); + }); + + it('should return filter group when it exists', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.NOT, + }); + const createOperation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const filterGroupId = + createResponse.body.data.createCoreViewFilterGroup.id; + + const getOperation = findViewFilterGroupOperationFactory({ + viewFilterGroupId: filterGroupId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterGroupStructure( + response.body.data.getCoreViewFilterGroup, + { + id: filterGroupId, + logicalOperator: ViewFilterGroupLogicalOperator.NOT, + viewId: testViewId, + }, + ); + }); + }); + + describe('createCoreViewFilterGroup', () => { + it('should create a new filter group with AND operator', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const operation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterGroupStructure( + response.body.data.createCoreViewFilterGroup, + { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + viewId: testViewId, + }, + ); + }); + + it('should create a filter group with OR operator', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + const operation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterGroupStructure( + response.body.data.createCoreViewFilterGroup, + { + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }, + ); + }); + + it('should create a filter group with NOT operator', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.NOT, + }); + const operation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterGroupStructure( + response.body.data.createCoreViewFilterGroup, + { + logicalOperator: ViewFilterGroupLogicalOperator.NOT, + }, + ); + }); + + it('should create a nested filter group with parent relationship', async () => { + const parentData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const parentOperation = createViewFilterGroupOperationFactory({ + data: parentData, + }); + const parentResponse = await makeGraphqlAPIRequest(parentOperation); + const parentId = parentResponse.body.data.createCoreViewFilterGroup.id; + + const childData = createViewFilterGroupData(testViewId, { + parentViewFilterGroupId: parentId, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + const childOperation = createViewFilterGroupOperationFactory({ + data: childData, + }); + const childResponse = await makeGraphqlAPIRequest(childOperation); + + assertGraphQLSuccessfulResponse(childResponse); + assertViewFilterGroupStructure( + childResponse.body.data.createCoreViewFilterGroup, + { + parentViewFilterGroupId: parentId, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }, + ); + }); + }); + + describe('updateCoreViewFilterGroup', () => { + it('should update an existing filter group', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const createOperation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const filterGroupId = + createResponse.body.data.createCoreViewFilterGroup.id; + + const updateInput = updateViewFilterGroupData({ + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + const updateOperation = updateViewFilterGroupOperationFactory({ + viewFilterGroupId: filterGroupId, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(updateOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.updateCoreViewFilterGroup).toMatchObject({ + id: filterGroupId, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + }); + + it('should update parent relationship of filter group', async () => { + const parentData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const parentOperation = createViewFilterGroupOperationFactory({ + data: parentData, + }); + const parentResponse = await makeGraphqlAPIRequest(parentOperation); + const parentId = parentResponse.body.data.createCoreViewFilterGroup.id; + + const childData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + const childOperation = createViewFilterGroupOperationFactory({ + data: childData, + }); + const childResponse = await makeGraphqlAPIRequest(childOperation); + const childId = childResponse.body.data.createCoreViewFilterGroup.id; + + const updateInput = updateViewFilterGroupData({ + parentViewFilterGroupId: parentId, + }); + const updateOperation = updateViewFilterGroupOperationFactory({ + viewFilterGroupId: childId, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(updateOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.updateCoreViewFilterGroup).toMatchObject({ + id: childId, + parentViewFilterGroupId: parentId, + }); + }); + + it('should throw error when updating non-existent filter group', async () => { + const updateInput = updateViewFilterGroupData({ + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const operation = updateViewFilterGroupOperationFactory({ + viewFilterGroupId: TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewFilterGroupExceptionMessage( + ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + ), + ); + }); + }); + + describe('deleteCoreViewFilterGroup', () => { + it('should delete an existing filter group', async () => { + const filterGroupData = createViewFilterGroupData(testViewId, { + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + const createOperation = createViewFilterGroupOperationFactory({ + data: filterGroupData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const filterGroupId = + createResponse.body.data.createCoreViewFilterGroup.id; + + const deleteOperation = deleteViewFilterGroupOperationFactory({ + viewFilterGroupId: filterGroupId, + }); + const response = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.deleteCoreViewFilterGroup).toBe(true); + + const getOperation = findViewFilterGroupOperationFactory({ + viewFilterGroupId: filterGroupId, + }); + const getResponse = await makeGraphqlAPIRequest(getOperation); + + expect(getResponse.body.data.getCoreViewFilterGroup).toBeNull(); + }); + + it('should throw an error when deleting non-existent filter group', async () => { + const operation = deleteViewFilterGroupOperationFactory({ + viewFilterGroupId: TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewFilterGroupExceptionMessage( + ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts new file mode 100644 index 0000000000..7d659f312f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts @@ -0,0 +1,225 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_FILTER_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { createViewFilterOperationFactory } from 'test/integration/graphql/utils/create-view-filter-operation-factory.util'; +import { deleteViewFilterOperationFactory } from 'test/integration/graphql/utils/delete-view-filter-operation-factory.util'; +import { findViewFiltersOperationFactory } from 'test/integration/graphql/utils/find-view-filters-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateViewFilterOperationFactory } from 'test/integration/graphql/utils/update-view-filter-operation-factory.util'; +import { createViewFilterData } from 'test/integration/graphql/utils/view-data-factory.util'; +import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util'; +import { + assertViewFilterStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { + ViewFilterExceptionMessageKey, + generateViewFilterExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view-filter.exception'; + +describe('View Filter Resolver', () => { + let testViewId: string; + + beforeEach(async () => { + await cleanupViewRecords(); + + const view = await createTestViewWithGraphQL({ + name: 'Test View for Filters', + }); + + testViewId = view.id; + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('getCoreViewFilters', () => { + it('should return empty array when no view filters exist', async () => { + const operation = findViewFiltersOperationFactory({ viewId: testViewId }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilters).toEqual([]); + }); + + it('should return view filters for a specific view', async () => { + const filterData = createViewFilterData(testViewId, { + operand: ViewFilterOperand.CONTAINS, + value: 'test', + }); + const createOperation = createViewFilterOperationFactory({ + data: filterData, + }); + + await makeGraphqlAPIRequest(createOperation); + + const getOperation = findViewFiltersOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewFilters).toHaveLength(1); + assertViewFilterStructure(response.body.data.getCoreViewFilters[0], { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + operand: ViewFilterOperand.CONTAINS, + value: 'test', + viewId: testViewId, + }); + }); + }); + + describe('createCoreViewFilter', () => { + it('should create a new view filter with string value', async () => { + const filterData = createViewFilterData(testViewId, { + operand: ViewFilterOperand.IS, + value: 'test value', + }); + + const operation = createViewFilterOperationFactory({ data: filterData }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterStructure(response.body.data.createCoreViewFilter, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + operand: ViewFilterOperand.IS, + value: 'test value', + viewId: testViewId, + }); + }); + + it('should create a view filter with numeric value', async () => { + const filterData = createViewFilterData(testViewId, { + operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + value: '100', + }); + + const operation = createViewFilterOperationFactory({ data: filterData }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterStructure(response.body.data.createCoreViewFilter, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + value: '100', + viewId: testViewId, + }); + }); + + it('should create a view filter with boolean value', async () => { + const operation = createViewFilterOperationFactory({ + data: createViewFilterData(testViewId, { + operand: ViewFilterOperand.IS, + value: 'true', + }), + }); + + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterStructure(response.body.data.createCoreViewFilter, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + operand: ViewFilterOperand.IS, + value: 'true', + viewId: testViewId, + }); + }); + }); + + describe('updateCoreViewFilter', () => { + it('should update an existing view filter', async () => { + const createOperation = createViewFilterOperationFactory({ + data: createViewFilterData(testViewId, { + operand: ViewFilterOperand.CONTAINS, + value: 'original', + }), + }); + + const createResponse = await makeGraphqlAPIRequest(createOperation); + + const viewFilterId = createResponse.body.data.createCoreViewFilter.id; + + const updateOperation = updateViewFilterOperationFactory({ + viewFilterId: viewFilterId, + data: { + operand: ViewFilterOperand.DOES_NOT_CONTAIN, + value: 'updated', + }, + }); + + const response = await makeGraphqlAPIRequest(updateOperation); + + assertGraphQLSuccessfulResponse(response); + assertViewFilterStructure(response.body.data.updateCoreViewFilter, { + id: viewFilterId, + operand: ViewFilterOperand.DOES_NOT_CONTAIN, + value: 'updated', + }); + }); + + it('should throw an error when updating non-existent view filter', async () => { + const operation = updateViewFilterOperationFactory({ + viewFilterId: TEST_NOT_EXISTING_VIEW_FILTER_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewFilterExceptionMessage( + ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_ID, + ), + ); + }); + }); + + describe('deleteCoreViewFilter', () => { + it('should delete an existing view filter', async () => { + const createOperation = createViewFilterOperationFactory({ + data: createViewFilterData(testViewId, { + operand: ViewFilterOperand.CONTAINS, + value: 'to delete', + }), + }); + + const createResponse = await makeGraphqlAPIRequest(createOperation); + + const viewFilterId = createResponse.body.data.createCoreViewFilter.id; + + const deleteOperation = deleteViewFilterOperationFactory({ + viewFilterId: viewFilterId, + }); + + const response = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.deleteCoreViewFilter).toBe(true); + }); + + it('should throw an error when deleting non-existent view filter', async () => { + const operation = deleteViewFilterOperationFactory({ + viewFilterId: TEST_NOT_EXISTING_VIEW_FILTER_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewFilterExceptionMessage( + ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-group-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-group-resolver.integration-spec.ts new file mode 100644 index 0000000000..e6f21d6818 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-group-resolver.integration-spec.ts @@ -0,0 +1,218 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_GROUP_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { createViewGroupOperationFactory } from 'test/integration/graphql/utils/create-view-group-operation-factory.util'; +import { deleteViewGroupOperationFactory } from 'test/integration/graphql/utils/delete-view-group-operation-factory.util'; +import { findViewGroupsOperationFactory } from 'test/integration/graphql/utils/find-view-groups-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateViewGroupOperationFactory } from 'test/integration/graphql/utils/update-view-group-operation-factory.util'; +import { + createViewGroupData, + updateViewGroupData, +} from 'test/integration/graphql/utils/view-data-factory.util'; +import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util'; +import { + assertViewGroupStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + generateViewGroupExceptionMessage, + ViewGroupExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-group.exception'; + +describe('View Group Resolver', () => { + let testViewId: string; + + beforeEach(async () => { + await cleanupViewRecords(); + + const view = await createTestViewWithGraphQL({ + name: 'Test View for Groups', + }); + + testViewId = view.id; + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('getCoreViewGroups', () => { + it('should return empty array when no view groups exist', async () => { + const operation = findViewGroupsOperationFactory({ viewId: testViewId }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewGroups).toEqual([]); + }); + + it('should return view groups for a specific view', async () => { + const groupData = createViewGroupData(testViewId, { + isVisible: true, + fieldValue: 'active', + position: 0, + }); + const createOperation = createViewGroupOperationFactory({ + data: groupData, + }); + + await makeGraphqlAPIRequest(createOperation); + + const getOperation = findViewGroupsOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewGroups).toHaveLength(1); + assertViewGroupStructure(response.body.data.getCoreViewGroups[0], { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + isVisible: true, + fieldValue: 'active', + position: 0, + viewId: testViewId, + }); + }); + }); + + describe('createCoreViewGroup', () => { + it('should create a new view group', async () => { + const groupData = createViewGroupData(testViewId, { + isVisible: false, + fieldValue: 'inactive', + position: 1, + }); + + const operation = createViewGroupOperationFactory({ + data: groupData, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewGroupStructure(response.body.data.createCoreViewGroup, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + isVisible: false, + fieldValue: 'inactive', + position: 1, + viewId: testViewId, + }); + }); + + it('should create a view group with null fieldValue', async () => { + const groupData = createViewGroupData(testViewId, { + isVisible: true, + fieldValue: '', + position: 2, + }); + + const operation = createViewGroupOperationFactory({ + data: groupData, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewGroupStructure(response.body.data.createCoreViewGroup, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + isVisible: true, + fieldValue: '', + position: 2, + }); + }); + }); + + describe('updateCoreViewGroup', () => { + it('should update an existing view group', async () => { + const groupData = createViewGroupData(testViewId, { + isVisible: true, + fieldValue: 'original', + position: 0, + }); + const createOperation = createViewGroupOperationFactory({ + data: groupData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const viewGroup = createResponse.body.data.createCoreViewGroup; + + const updateInput = updateViewGroupData({ + isVisible: false, + fieldValue: 'updated', + position: 5, + }); + const updateOperation = updateViewGroupOperationFactory({ + viewGroupId: viewGroup.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(updateOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.updateCoreViewGroup).toMatchObject({ + id: viewGroup.id, + isVisible: false, + fieldValue: 'updated', + position: 5, + }); + }); + + it('should throw an error when updating non-existent view group', async () => { + const operation = updateViewGroupOperationFactory({ + viewGroupId: TEST_NOT_EXISTING_VIEW_GROUP_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewGroupExceptionMessage( + ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_GROUP_ID, + ), + ); + }); + }); + + describe('deleteCoreViewGroup', () => { + it('should delete an existing view group', async () => { + const groupData = createViewGroupData(testViewId, { + isVisible: true, + fieldValue: 'to delete', + position: 0, + }); + const createOperation = createViewGroupOperationFactory({ + data: groupData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const viewGroup = createResponse.body.data.createCoreViewGroup; + + const deleteOperation = deleteViewGroupOperationFactory({ + viewGroupId: viewGroup.id, + }); + const response = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.deleteCoreViewGroup).toBe(true); + }); + + it('should throw an error when deleting non-existent view group', async () => { + const operation = deleteViewGroupOperationFactory({ + viewGroupId: TEST_NOT_EXISTING_VIEW_GROUP_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewGroupExceptionMessage( + ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_GROUP_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-resolver.integration-spec.ts new file mode 100644 index 0000000000..e7050a5b38 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-resolver.integration-spec.ts @@ -0,0 +1,261 @@ +import { + TEST_NOT_EXISTING_VIEW_ID, + TEST_OBJECT_METADATA_1_ID, + TEST_OBJECT_METADATA_2_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { createViewOperationFactory } from 'test/integration/graphql/utils/create-view-operation-factory.util'; +import { deleteViewOperationFactory } from 'test/integration/graphql/utils/delete-view-operation-factory.util'; +import { findViewOperationFactory } from 'test/integration/graphql/utils/find-view-operation-factory.util'; +import { findViewsOperationFactory } from 'test/integration/graphql/utils/find-views-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateViewOperationFactory } from 'test/integration/graphql/utils/update-view-operation-factory.util'; +import { + createViewData, + updateViewData, +} from 'test/integration/graphql/utils/view-data-factory.util'; +import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util'; +import { + assertViewStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; +import { + ViewExceptionMessageKey, + generateViewExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view.exception'; + +describe('View Resolver', () => { + beforeEach(async () => { + await cleanupViewRecords(); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('getCoreViews', () => { + it('should return empty array when no views exist', async () => { + const operation = findViewsOperationFactory(); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViews).toEqual([]); + }); + + it('should return all views for workspace when no objectMetadataId provided', async () => { + const viewName = 'Test View'; + + await createTestViewWithGraphQL({ + name: viewName, + }); + + const viewData = createViewData({ + name: viewName, + }); + + const operation = findViewsOperationFactory(); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViews).toHaveLength(1); + expect(response.body.data.getCoreViews[0]).toMatchObject(viewData); + }); + + it('should filter views by objectMetadataId when provided', async () => { + const object1ViewName = 'View for Object 1'; + const object2ViewName = 'View for Object 2'; + + await Promise.all([ + createTestViewWithGraphQL({ + name: object1ViewName, + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + }), + createTestViewWithGraphQL({ + name: object2ViewName, + objectMetadataId: TEST_OBJECT_METADATA_2_ID, + }), + ]); + + const operation = findViewsOperationFactory({ + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViews).toHaveLength(1); + expect(response.body.data.getCoreViews[0]).toMatchObject({ + name: object1ViewName, + }); + }); + }); + + describe('getCoreView', () => { + it('should return null when view does not exist', async () => { + const operation = findViewOperationFactory({ + viewId: TEST_NOT_EXISTING_VIEW_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreView).toBeNull(); + }); + + it('should return view when it exists', async () => { + const viewName = 'Test View for Get'; + + const view = await createTestViewWithGraphQL({ + name: viewName, + }); + + const operation = findViewOperationFactory({ viewId: view.id }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewStructure(response.body.data.getCoreView, { + id: view.id, + name: viewName, + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + }); + }); + }); + + describe('createCoreView', () => { + it('should create a new view with all properties', async () => { + const input = { + name: 'Kanban View', + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + icon: 'IconDeal', + type: ViewType.KANBAN, + key: 'OPPORTUNITIES', + position: 1, + isCompact: true, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }; + + const operation = createViewOperationFactory({ data: input }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewStructure(response.body.data.createCoreView, { + name: input.name, + objectMetadataId: input.objectMetadataId, + type: input.type, + key: input.key, + icon: input.icon, + position: input.position, + isCompact: input.isCompact, + openRecordIn: input.openRecordIn, + }); + }); + + it('should create a view with minimum required fields', async () => { + const input = { + name: 'Minimal View', + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + icon: 'IconList', + }; + + const operation = createViewOperationFactory({ data: input }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewStructure(response.body.data.createCoreView, { + name: input.name, + objectMetadataId: input.objectMetadataId, + icon: input.icon, + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + }); + }); + + describe('updateCoreView', () => { + it('should update an existing view', async () => { + const view = await createTestViewWithGraphQL({ + name: 'Original View', + type: ViewType.TABLE, + isCompact: false, + }); + + const updateInput = updateViewData({ + name: 'Updated View', + type: ViewType.KANBAN, + isCompact: true, + }); + + const operation = updateViewOperationFactory({ + viewId: view.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.updateCoreView).toMatchObject({ + id: view.id, + ...updateInput, + }); + }); + + it('should throw error when updating non-existent view', async () => { + const operation = updateViewOperationFactory({ + viewId: TEST_NOT_EXISTING_VIEW_ID, + data: { name: 'Non-existent View' }, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_ID, + ), + ); + }); + }); + + describe('deleteCoreView', () => { + it('should delete an existing view', async () => { + const view = await createTestViewWithGraphQL({ + name: 'View to Delete', + }); + + const deleteOperation = deleteViewOperationFactory({ viewId: view.id }); + const deleteResponse = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(deleteResponse); + expect(deleteResponse.body.data.deleteCoreView).toBe(true); + + const getOperation = findViewOperationFactory({ viewId: view.id }); + const getResponse = await makeGraphqlAPIRequest(getOperation); + + expect(getResponse.body.data.getCoreView).toBeNull(); + }); + + it('should throw an error when deleting non-existent view', async () => { + const operation = deleteViewOperationFactory({ + viewId: TEST_NOT_EXISTING_VIEW_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-sort-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-sort-resolver.integration-spec.ts new file mode 100644 index 0000000000..be36f5c02a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-sort-resolver.integration-spec.ts @@ -0,0 +1,194 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_SORT_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { createViewSortOperationFactory } from 'test/integration/graphql/utils/create-view-sort-operation-factory.util'; +import { deleteViewSortOperationFactory } from 'test/integration/graphql/utils/delete-view-sort-operation-factory.util'; +import { findViewSortsOperationFactory } from 'test/integration/graphql/utils/find-view-sorts-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateViewSortOperationFactory } from 'test/integration/graphql/utils/update-view-sort-operation-factory.util'; +import { + createViewSortData, + updateViewSortData, +} from 'test/integration/graphql/utils/view-data-factory.util'; +import { createTestViewWithGraphQL } from 'test/integration/graphql/utils/view-graphql.util'; +import { + assertViewSortStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; +import { + generateViewSortExceptionMessage, + ViewSortExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-sort.exception'; + +describe('View Sort Resolver', () => { + let testViewId: string; + + beforeEach(async () => { + await cleanupViewRecords(); + + const view = await createTestViewWithGraphQL({ + name: 'Test View for Sorts', + }); + + testViewId = view.id; + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('getCoreViewSorts', () => { + it('should return empty array when no view sorts exist', async () => { + const operation = findViewSortsOperationFactory({ viewId: testViewId }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewSorts).toEqual([]); + }); + + it('should return view sorts for a specific view', async () => { + const sortData = createViewSortData(testViewId, { + direction: ViewSortDirection.ASC, + }); + const createOperation = createViewSortOperationFactory({ + data: sortData, + }); + + await makeGraphqlAPIRequest(createOperation); + + const getOperation = findViewSortsOperationFactory({ + viewId: testViewId, + }); + const response = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getCoreViewSorts).toHaveLength(1); + assertViewSortStructure(response.body.data.getCoreViewSorts[0], { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + direction: ViewSortDirection.ASC, + viewId: testViewId, + }); + }); + }); + + describe('createCoreViewSort', () => { + it('should create a new view sort with ASC direction', async () => { + const sortData = createViewSortData(testViewId, { + direction: ViewSortDirection.ASC, + }); + + const operation = createViewSortOperationFactory({ data: sortData }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewSortStructure(response.body.data.createCoreViewSort, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + direction: ViewSortDirection.ASC, + viewId: testViewId, + }); + }); + + it('should create a view sort with DESC direction', async () => { + const sortData = createViewSortData(testViewId, { + direction: ViewSortDirection.DESC, + }); + + const operation = createViewSortOperationFactory({ data: sortData }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertViewSortStructure(response.body.data.createCoreViewSort, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + direction: ViewSortDirection.DESC, + viewId: testViewId, + }); + }); + }); + + describe('updateCoreViewSort', () => { + it('should update an existing view sort', async () => { + const sortData = createViewSortData(testViewId, { + direction: ViewSortDirection.ASC, + }); + const createOperation = createViewSortOperationFactory({ + data: sortData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const viewSort = createResponse.body.data.createCoreViewSort; + + const updateInput = updateViewSortData({ + direction: ViewSortDirection.DESC, + }); + const updateOperation = updateViewSortOperationFactory({ + viewSortId: viewSort.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(updateOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.updateCoreViewSort).toMatchObject({ + id: viewSort.id, + direction: 'DESC', + }); + }); + + it('should throw an error when updating non-existent view sort', async () => { + const operation = updateViewSortOperationFactory({ + viewSortId: TEST_NOT_EXISTING_VIEW_SORT_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewSortExceptionMessage( + ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_SORT_ID, + ), + ); + }); + }); + + describe('deleteCoreViewSort', () => { + it('should delete an existing view sort', async () => { + const sortData = createViewSortData(testViewId); + const createOperation = createViewSortOperationFactory({ + data: sortData, + }); + const createResponse = await makeGraphqlAPIRequest(createOperation); + const viewSort = createResponse.body.data.createCoreViewSort; + + const deleteOperation = deleteViewSortOperationFactory({ + viewSortId: viewSort.id, + }); + const response = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.deleteCoreViewSort).toBe(true); + }); + + it('should throw an error when deleting non-existent view sort', async () => { + const operation = deleteViewSortOperationFactory({ + viewSortId: TEST_NOT_EXISTING_VIEW_SORT_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generateViewSortExceptionMessage( + ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_SORT_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-view-field-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-view-field-operation-factory.util.ts new file mode 100644 index 0000000000..26c323bacc --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-view-field-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; + +type CreateViewFieldOperationFactoryParams = { + gqlFields?: string; + data?: Partial; +}; + +export const createViewFieldOperationFactory = ({ + gqlFields = VIEW_FIELD_GQL_FIELDS, + data = {}, +}: CreateViewFieldOperationFactoryParams = {}) => ({ + query: gql` + mutation CreateCoreViewField($input: CreateViewFieldInput!) { + createCoreViewField(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-view-filter-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-view-filter-group-operation-factory.util.ts new file mode 100644 index 0000000000..be2719f668 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-view-filter-group-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; + +type CreateViewFilterGroupOperationFactoryParams = { + gqlFields?: string; + data?: Partial; +}; + +export const createViewFilterGroupOperationFactory = ({ + gqlFields = VIEW_FILTER_GROUP_GQL_FIELDS, + data = {}, +}: CreateViewFilterGroupOperationFactoryParams = {}) => ({ + query: gql` + mutation CreateCoreViewFilterGroup($input: CreateViewFilterGroupInput!) { + createCoreViewFilterGroup(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-view-filter-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-view-filter-operation-factory.util.ts new file mode 100644 index 0000000000..4405196279 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-view-filter-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; + +type CreateViewFilterOperationFactoryParams = { + gqlFields?: string; + data?: Partial; +}; + +export const createViewFilterOperationFactory = ({ + gqlFields = VIEW_FILTER_GQL_FIELDS, + data = {}, +}: CreateViewFilterOperationFactoryParams = {}) => ({ + query: gql` + mutation CreateCoreViewFilter($input: CreateViewFilterInput!) { + createCoreViewFilter(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-view-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-view-group-operation-factory.util.ts new file mode 100644 index 0000000000..e395c7f57e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-view-group-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; + +type CreateViewGroupOperationFactoryParams = { + gqlFields?: string; + data?: Partial; +}; + +export const createViewGroupOperationFactory = ({ + gqlFields = VIEW_GROUP_GQL_FIELDS, + data = {}, +}: CreateViewGroupOperationFactoryParams = {}) => ({ + query: gql` + mutation CreateCoreViewGroup($input: CreateViewGroupInput!) { + createCoreViewGroup(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-view-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-view-operation-factory.util.ts new file mode 100644 index 0000000000..cb946c8075 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-view-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { View } from 'src/engine/core-modules/view/entities/view.entity'; + +type CreateViewOperationFactoryParams = { + gqlFields?: string; + data?: Partial; +}; + +export const createViewOperationFactory = ({ + gqlFields = VIEW_GQL_FIELDS, + data = {}, +}: CreateViewOperationFactoryParams = {}) => ({ + query: gql` + mutation CreateCoreView($input: CreateViewInput!) { + createCoreView(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-view-sort-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-view-sort-operation-factory.util.ts new file mode 100644 index 0000000000..d278524ec6 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-view-sort-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { VIEW_SORT_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; + +type CreateViewSortOperationFactoryParams = { + gqlFields?: string; + data?: Partial; +}; + +export const createViewSortOperationFactory = ({ + gqlFields = VIEW_SORT_GQL_FIELDS, + data = {}, +}: CreateViewSortOperationFactoryParams = {}) => ({ + query: gql` + mutation CreateCoreViewSort($input: CreateViewSortInput!) { + createCoreViewSort(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-view-field-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-view-field-operation-factory.util.ts new file mode 100644 index 0000000000..4251298564 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-view-field-operation-factory.util.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const deleteViewFieldOperationFactory = ({ + viewFieldId, +}: { + viewFieldId: string; +}) => ({ + query: gql` + mutation DeleteCoreViewField($id: String!) { + deleteCoreViewField(id: $id) + } + `, + variables: { + id: viewFieldId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-view-filter-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-view-filter-group-operation-factory.util.ts new file mode 100644 index 0000000000..5dc76f384a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-view-filter-group-operation-factory.util.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const deleteViewFilterGroupOperationFactory = ({ + viewFilterGroupId, +}: { + viewFilterGroupId: string; +}) => ({ + query: gql` + mutation DeleteCoreViewFilterGroup($id: String!) { + deleteCoreViewFilterGroup(id: $id) + } + `, + variables: { + id: viewFilterGroupId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-view-filter-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-view-filter-operation-factory.util.ts new file mode 100644 index 0000000000..12e585e8b2 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-view-filter-operation-factory.util.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const deleteViewFilterOperationFactory = ({ + viewFilterId, +}: { + viewFilterId: string; +}) => ({ + query: gql` + mutation DeleteCoreViewFilter($id: String!) { + deleteCoreViewFilter(id: $id) + } + `, + variables: { + id: viewFilterId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-view-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-view-group-operation-factory.util.ts new file mode 100644 index 0000000000..b09f0b9cfd --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-view-group-operation-factory.util.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const deleteViewGroupOperationFactory = ({ + viewGroupId, +}: { + viewGroupId: string; +}) => ({ + query: gql` + mutation DeleteCoreViewGroup($id: String!) { + deleteCoreViewGroup(id: $id) + } + `, + variables: { + id: viewGroupId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-view-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-view-operation-factory.util.ts new file mode 100644 index 0000000000..e88dbc035d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-view-operation-factory.util.ts @@ -0,0 +1,12 @@ +import gql from 'graphql-tag'; + +export const deleteViewOperationFactory = ({ viewId }: { viewId: string }) => ({ + query: gql` + mutation DeleteCoreView($id: String!) { + deleteCoreView(id: $id) + } + `, + variables: { + id: viewId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-view-sort-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-view-sort-operation-factory.util.ts new file mode 100644 index 0000000000..906893818b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-view-sort-operation-factory.util.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const deleteViewSortOperationFactory = ({ + viewSortId, +}: { + viewSortId: string; +}) => ({ + query: gql` + mutation DeleteCoreViewSort($id: String!) { + deleteCoreViewSort(id: $id) + } + `, + variables: { + id: viewSortId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-fields-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-fields-operation-factory.util.ts new file mode 100644 index 0000000000..a8bcf7a05b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-fields-operation-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewFieldsOperationFactory = ({ + gqlFields = VIEW_FIELD_GQL_FIELDS, + viewId, +}: { + gqlFields?: string; + viewId: string; +}) => ({ + query: gql` + query GetCoreViewFields($viewId: String!) { + getCoreViewFields(viewId: $viewId) { + ${gqlFields} + } + } + `, + variables: { + viewId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-filter-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-filter-group-operation-factory.util.ts new file mode 100644 index 0000000000..991ac2626d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-filter-group-operation-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewFilterGroupOperationFactory = ({ + gqlFields = VIEW_FILTER_GROUP_GQL_FIELDS, + viewFilterGroupId, +}: { + gqlFields?: string; + viewFilterGroupId: string; +}) => ({ + query: gql` + query GetCoreViewFilterGroup($id: String!) { + getCoreViewFilterGroup(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: viewFilterGroupId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-filter-groups-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-filter-groups-operation-factory.util.ts new file mode 100644 index 0000000000..df4174bb86 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-filter-groups-operation-factory.util.ts @@ -0,0 +1,19 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewFilterGroupsOperationFactory = ({ + gqlFields = VIEW_FILTER_GROUP_GQL_FIELDS, + viewId, +}: { + gqlFields?: string; + viewId?: string; +} = {}) => ({ + query: gql` + query GetCoreViewFilterGroups($viewId: String) { + getCoreViewFilterGroups(viewId: $viewId) { + ${gqlFields} + } + } + `, + variables: viewId ? { viewId } : {}, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-filters-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-filters-operation-factory.util.ts new file mode 100644 index 0000000000..c93dc6f2a8 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-filters-operation-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewFiltersOperationFactory = ({ + gqlFields = VIEW_FILTER_GQL_FIELDS, + viewId, +}: { + gqlFields?: string; + viewId: string; +}) => ({ + query: gql` + query GetCoreViewFilters($viewId: String!) { + getCoreViewFilters(viewId: $viewId) { + ${gqlFields} + } + } + `, + variables: { + viewId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-groups-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-groups-operation-factory.util.ts new file mode 100644 index 0000000000..dbd86f4c6f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-groups-operation-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewGroupsOperationFactory = ({ + gqlFields = VIEW_GROUP_GQL_FIELDS, + viewId, +}: { + gqlFields?: string; + viewId: string; +}) => ({ + query: gql` + query GetCoreViewGroups($viewId: String!) { + getCoreViewGroups(viewId: $viewId) { + ${gqlFields} + } + } + `, + variables: { + viewId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-operation-factory.util.ts new file mode 100644 index 0000000000..477503e335 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-operation-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewOperationFactory = ({ + gqlFields = VIEW_GQL_FIELDS, + viewId, +}: { + gqlFields?: string; + viewId: string; +}) => ({ + query: gql` + query GetCoreView($id: String!) { + getCoreView(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: viewId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-view-sorts-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-view-sorts-operation-factory.util.ts new file mode 100644 index 0000000000..fd1276e16b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-view-sorts-operation-factory.util.ts @@ -0,0 +1,21 @@ +import gql from 'graphql-tag'; +import { VIEW_SORT_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewSortsOperationFactory = ({ + gqlFields = VIEW_SORT_GQL_FIELDS, + viewId, +}: { + gqlFields?: string; + viewId: string; +}) => ({ + query: gql` + query GetCoreViewSorts($viewId: String!) { + getCoreViewSorts(viewId: $viewId) { + ${gqlFields} + } + } + `, + variables: { + viewId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-views-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-views-operation-factory.util.ts new file mode 100644 index 0000000000..a4c81a770c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-views-operation-factory.util.ts @@ -0,0 +1,19 @@ +import gql from 'graphql-tag'; +import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const findViewsOperationFactory = ({ + gqlFields = VIEW_GQL_FIELDS, + objectMetadataId, +}: { + gqlFields?: string; + objectMetadataId?: string; +} = {}) => ({ + query: gql` + query GetCoreViews($objectMetadataId: String) { + getCoreViews(objectMetadataId: $objectMetadataId) { + ${gqlFields} + } + } + `, + variables: objectMetadataId ? { objectMetadataId } : {}, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/graphql-test-assertions.util.ts b/packages/twenty-server/test/integration/graphql/utils/graphql-test-assertions.util.ts new file mode 100644 index 0000000000..1c30fbcbbb --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/graphql-test-assertions.util.ts @@ -0,0 +1,45 @@ +import { + BaseGraphQLError, + ErrorCode, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export interface GraphQLResponse> { + status: number; + body: { + data?: T; + errors?: BaseGraphQLError[]; + }; +} + +export const assertGraphQLSuccessfulResponse = < + T extends Record, +>( + response: GraphQLResponse, + expectedData?: Partial, +) => { + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + if (expectedData) { + expect(response.body.data).toMatchObject(expectedData); + } +}; + +export const assertGraphQLErrorResponse = >( + response: GraphQLResponse, + expectedErrorCode: ErrorCode, + expectedErrorMessage?: string, +) => { + expect(response.status).toBe(200); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors).toHaveLength(1); + + if (expectedErrorCode && response.body.errors) { + expect(response.body.errors[0].extensions.code).toBe(expectedErrorCode); + } + + if (expectedErrorMessage && response.body.errors) { + expect(response.body.errors[0].message).toBe(expectedErrorMessage); + } +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/update-view-field-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-view-field-operation-factory.util.ts new file mode 100644 index 0000000000..087103f87d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-view-field-operation-factory.util.ts @@ -0,0 +1,24 @@ +import gql from 'graphql-tag'; +import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const updateViewFieldOperationFactory = ({ + gqlFields = VIEW_FIELD_GQL_FIELDS, + viewFieldId, + data = {}, +}: { + gqlFields?: string; + viewFieldId: string; + data?: object; +}) => ({ + query: gql` + mutation UpdateCoreViewField($id: String!, $input: UpdateViewFieldInput!) { + updateCoreViewField(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: viewFieldId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-view-filter-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-view-filter-group-operation-factory.util.ts new file mode 100644 index 0000000000..df174426db --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-view-filter-group-operation-factory.util.ts @@ -0,0 +1,24 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const updateViewFilterGroupOperationFactory = ({ + gqlFields = VIEW_FILTER_GROUP_GQL_FIELDS, + viewFilterGroupId, + data = {}, +}: { + gqlFields?: string; + viewFilterGroupId: string; + data?: object; +}) => ({ + query: gql` + mutation UpdateCoreViewFilterGroup($id: String!, $input: UpdateViewFilterGroupInput!) { + updateCoreViewFilterGroup(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: viewFilterGroupId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-view-filter-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-view-filter-operation-factory.util.ts new file mode 100644 index 0000000000..de4a4e9adc --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-view-filter-operation-factory.util.ts @@ -0,0 +1,24 @@ +import gql from 'graphql-tag'; +import { VIEW_FILTER_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const updateViewFilterOperationFactory = ({ + gqlFields = VIEW_FILTER_GQL_FIELDS, + viewFilterId, + data = {}, +}: { + gqlFields?: string; + viewFilterId: string; + data?: object; +}) => ({ + query: gql` + mutation UpdateCoreViewFilter($id: String!, $input: UpdateViewFilterInput!) { + updateCoreViewFilter(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: viewFilterId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-view-group-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-view-group-operation-factory.util.ts new file mode 100644 index 0000000000..ba03c94c1e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-view-group-operation-factory.util.ts @@ -0,0 +1,24 @@ +import gql from 'graphql-tag'; +import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const updateViewGroupOperationFactory = ({ + gqlFields = VIEW_GROUP_GQL_FIELDS, + viewGroupId, + data = {}, +}: { + gqlFields?: string; + viewGroupId: string; + data?: object; +}) => ({ + query: gql` + mutation UpdateCoreViewGroup($id: String!, $input: UpdateViewGroupInput!) { + updateCoreViewGroup(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: viewGroupId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-view-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-view-operation-factory.util.ts new file mode 100644 index 0000000000..52d21d8074 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-view-operation-factory.util.ts @@ -0,0 +1,24 @@ +import gql from 'graphql-tag'; +import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const updateViewOperationFactory = ({ + gqlFields = VIEW_GQL_FIELDS, + viewId, + data = {}, +}: { + gqlFields?: string; + viewId: string; + data?: object; +}) => ({ + query: gql` + mutation UpdateCoreView($id: String!, $input: UpdateViewInput!) { + updateCoreView(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: viewId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-view-sort-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-view-sort-operation-factory.util.ts new file mode 100644 index 0000000000..b7a0a3c189 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-view-sort-operation-factory.util.ts @@ -0,0 +1,24 @@ +import gql from 'graphql-tag'; +import { VIEW_SORT_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +export const updateViewSortOperationFactory = ({ + gqlFields = VIEW_SORT_GQL_FIELDS, + viewSortId, + data = {}, +}: { + gqlFields?: string; + viewSortId: string; + data?: object; +}) => ({ + query: gql` + mutation UpdateCoreViewSort($id: String!, $input: UpdateViewSortInput!) { + updateCoreViewSort(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: viewSortId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/view-data-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/view-data-factory.util.ts new file mode 100644 index 0000000000..6bf89d9f32 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/view-data-factory.util.ts @@ -0,0 +1,121 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_OBJECT_METADATA_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; + +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; + +export const createViewData = (overrides: Partial = {}) => ({ + name: 'Test View', + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + ...overrides, +}); + +export const updateViewData = (overrides: Partial = {}) => ({ + name: 'Updated View', + type: ViewType.KANBAN, + isCompact: true, + ...overrides, +}); + +export const createViewFieldData = ( + viewId: string, + overrides: Partial = {}, +) => ({ + viewId, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + position: 0, + isVisible: true, + size: 150, + ...overrides, +}); + +export const updateViewFieldData = (overrides: Partial = {}) => ({ + position: 5, + isVisible: false, + size: 300, + ...overrides, +}); + +export const createViewSortData = ( + viewId: string, + overrides: Partial = {}, +) => ({ + viewId, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + direction: ViewSortDirection.ASC, + ...overrides, +}); + +export const updateViewSortData = (overrides: Partial = {}) => ({ + direction: ViewSortDirection.DESC, + ...overrides, +}); + +export const createViewFilterData = ( + viewId: string, + overrides: Partial = {}, +) => ({ + viewId, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + operand: ViewFilterOperand.IS, + value: 'test-value', + ...overrides, +}); + +export const updateViewFilterData = (overrides: Partial = {}) => ({ + operand: ViewFilterOperand.IS_NOT, + value: 'updated-value', + ...overrides, +}); + +export const createViewGroupData = ( + viewId: string, + overrides: Partial = {}, +) => ({ + viewId, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + fieldValue: 'test-group-value', + isVisible: true, + position: 0, + ...overrides, +}); + +export const updateViewGroupData = (overrides: Partial = {}) => ({ + fieldValue: 'updated-group-value', + isVisible: false, + position: 1, + ...overrides, +}); + +export const createViewFilterGroupData = ( + viewId: string, + overrides: Partial = {}, +) => ({ + viewId, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + ...overrides, +}); + +export const updateViewFilterGroupData = ( + overrides: Partial = {}, +) => ({ + logicalOperator: ViewFilterGroupLogicalOperator.OR, + ...overrides, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/view-graphql.util.ts b/packages/twenty-server/test/integration/graphql/utils/view-graphql.util.ts new file mode 100644 index 0000000000..2e268bb366 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/view-graphql.util.ts @@ -0,0 +1,34 @@ +import { GraphQLResponse } from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { View } from 'src/engine/core-modules/view/entities/view.entity'; + +import { createViewOperationFactory } from './create-view-operation-factory.util'; +import { createViewData } from './view-data-factory.util'; + +interface CreateViewResponse extends Record { + createCoreView: View; +} + +export const createTestViewWithGraphQL = async ( + overrides: Partial = {}, +): Promise => { + const input = createViewData(overrides); + + const operation = createViewOperationFactory({ data: input }); + const response = (await makeGraphqlAPIRequest( + operation, + )) as GraphQLResponse; + + if (response.body.errors) { + throw new Error( + `Failed to create test view: ${JSON.stringify(response.body.errors)}`, + ); + } + + if (!response.body.data) { + throw new Error('No data returned from createTestViewWithGraphQL'); + } + + return response.body.data.createCoreView; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts index 2f5154ada7..1c31475709 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts @@ -12,6 +12,8 @@ import { createOneObjectMetadata } from 'test/integration/metadata/suites/object import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; import { FieldMetadataType } from 'twenty-shared/types'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; + describe('deleteOne', () => { describe('Kanban aggregate operation', () => { let listingObjectId = ''; @@ -54,7 +56,7 @@ describe('deleteOne', () => { kanbanAggregateOperation: 'MAX', objectMetadataId: listingObjectId, name: 'By Type', - type: 'kanban', + type: ViewType.KANBAN, icon: 'IconLayoutKanban', }, }); diff --git a/packages/twenty-server/test/integration/rest/suites/view-field.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view-field.integration-spec.ts new file mode 100644 index 0000000000..726c85326e --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/view-field.integration-spec.ts @@ -0,0 +1,271 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_FIELD_ID, + TEST_VIEW_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { + createTestViewFieldWithRestApi, + createTestViewWithRestApi, + deleteTestViewFieldWithRestApi, +} from 'test/integration/rest/utils/view-rest-api.util'; +import { + assertViewFieldStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { + generateViewFieldExceptionMessage, + ViewFieldExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-field.exception'; + +describe('View Field REST API', () => { + beforeEach(async () => { + await cleanupViewRecords(); + + await createTestViewWithRestApi({ + name: 'Test View for Fields', + }); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('GET /metadata/viewFields', () => { + it('should return empty array when no view fields exist', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFields?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual([]); + }); + + it('should return all view fields for workspace when no viewId provided', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/viewFields', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return view fields for a specific view after creating one', async () => { + const viewField = await createTestViewFieldWithRestApi({ + position: 0, + isVisible: true, + size: 150, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFields?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const returnedViewField = response.body[0]; + + assertViewFieldStructure(returnedViewField, { + id: viewField.id, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + position: 0, + isVisible: true, + size: 150, + }); + + await deleteTestViewFieldWithRestApi(viewField.id); + }); + }); + + describe('POST /metadata/viewFields', () => { + it('should create a new view field', async () => { + const viewField = await createTestViewFieldWithRestApi({ + position: 1, + isVisible: true, + size: 200, + }); + + assertViewFieldStructure(viewField, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + position: 1, + isVisible: true, + size: 200, + }); + + await deleteTestViewFieldWithRestApi(viewField.id); + }); + + it('should create a hidden view field', async () => { + const hiddenField = await createTestViewFieldWithRestApi({ + position: 2, + isVisible: false, + size: 100, + }); + + assertViewFieldStructure(hiddenField, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + position: 2, + isVisible: false, + size: 100, + }); + + await deleteTestViewFieldWithRestApi(hiddenField.id); + }); + }); + + describe('GET /metadata/viewFields/:id', () => { + it('should return a view field by id', async () => { + const viewField = await createTestViewFieldWithRestApi({ + position: 0, + isVisible: true, + size: 150, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFields/${viewField.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewFieldStructure(response.body, { + id: viewField.id, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + }); + + await deleteTestViewFieldWithRestApi(viewField.id); + }); + + it('should return empty object for non-existent view field', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFields/${TEST_NOT_EXISTING_VIEW_FIELD_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual({}); + }); + }); + + describe('PATCH /metadata/viewFields/:id', () => { + it('should update an existing view field', async () => { + const viewField = await createTestViewFieldWithRestApi({ + position: 0, + isVisible: true, + size: 150, + }); + + const updateData = { + position: 5, + isVisible: false, + size: 300, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFields/${viewField.id}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewFieldStructure(response.body, { + id: viewField.id, + position: 5, + isVisible: false, + size: 300, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + }); + + await deleteTestViewFieldWithRestApi(viewField.id); + }); + + it('should return 404 error when updating non-existent view field', async () => { + const updateData = { + position: 5, + isVisible: false, + size: 300, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFields/${TEST_NOT_EXISTING_VIEW_FIELD_ID}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewFieldExceptionMessage( + ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FIELD_ID, + ), + ); + }); + }); + + describe('DELETE /metadata/viewFields/:id', () => { + it('should delete an existing view field', async () => { + const viewField = await createTestViewFieldWithRestApi({ + position: 0, + isVisible: true, + size: 150, + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFields/${viewField.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + expect(deleteResponse.body.success).toBe(true); + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFields/${viewField.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(getResponse); + expect(getResponse.body).toEqual({}); + }); + + it('should return 404 error when deleting non-existent view field', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFields/${TEST_NOT_EXISTING_VIEW_FIELD_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewFieldExceptionMessage( + ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FIELD_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/view-filter-group.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view-filter-group.integration-spec.ts new file mode 100644 index 0000000000..e5a0fde717 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/view-filter-group.integration-spec.ts @@ -0,0 +1,445 @@ +import { + TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + TEST_VIEW_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { + createTestViewFilterGroupWithRestApi, + createTestViewWithRestApi, + deleteTestViewFilterGroupWithRestApi, +} from 'test/integration/rest/utils/view-rest-api.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { + assertViewFilterGroupStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ViewFilterGroupLogicalOperator } from 'src/engine/core-modules/view/enums/view-filter-group-logical-operator'; +import { + generateViewFilterGroupExceptionMessage, + ViewFilterGroupExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-filter-group.exception'; + +describe('View Filter Group REST API', () => { + beforeEach(async () => { + await cleanupViewRecords(); + + await createTestViewWithRestApi({ + name: generateRecordName('Test View for Filter Groups'), + }); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('GET /metadata/viewFilterGroups', () => { + it('should return empty array when no view filter groups exist', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilterGroups?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual([]); + }); + + it('should return all view filter groups for workspace when no viewId provided', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/viewFilterGroups', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return view filter groups for a specific view after creating one', async () => { + const viewFilterGroup = await createTestViewFilterGroupWithRestApi({ + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilterGroups?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const returnedViewFilterGroup = response.body[0]; + + assertViewFilterGroupStructure(returnedViewFilterGroup, { + id: viewFilterGroup.id, + viewId: TEST_VIEW_1_ID, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + + await deleteTestViewFilterGroupWithRestApi(viewFilterGroup.id); + }); + + it('should return nested filter groups with parent relationships', async () => { + const parentData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'AND', + }; + + const parentResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: parentData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const parentId = parentResponse.body.id; + + const childData = { + viewId: TEST_VIEW_1_ID, + parentViewFilterGroupId: parentId, + logicalOperator: 'OR', + }; + + const childResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: childData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const childId = childResponse.body.id; + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilterGroups?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + + const parentGroup = response.body.find( + (group: any) => group.parentViewFilterGroupId === null, + ); + const childGroup = response.body.find( + (group: any) => group.parentViewFilterGroupId !== null, + ); + + expect(parentGroup).toBeDefined(); + expect(parentGroup.logicalOperator).toBe('AND'); + expect(parentGroup.parentViewFilterGroupId).toBeNull(); + + expect(childGroup).toBeDefined(); + expect(childGroup.parentViewFilterGroupId).toBe(parentId); + expect(childGroup.logicalOperator).toBe('OR'); + + const deleteChildResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilterGroups/${childId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteChildResponse); + + const deleteParentResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilterGroups/${parentId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteParentResponse); + }); + }); + + describe('POST /metadata/viewFilterGroups', () => { + it('should create a new filter group with AND operator', async () => { + const viewFilterGroup = await createTestViewFilterGroupWithRestApi({ + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + + assertViewFilterGroupStructure(viewFilterGroup, { + viewId: TEST_VIEW_1_ID, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }); + expect(viewFilterGroup.parentViewFilterGroupId).toBeNull(); + }); + + it('should create a filter group with OR operator', async () => { + const orGroup = await createTestViewFilterGroupWithRestApi({ + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + + assertViewFilterGroupStructure(orGroup, { + viewId: TEST_VIEW_1_ID, + logicalOperator: ViewFilterGroupLogicalOperator.OR, + }); + expect(orGroup.parentViewFilterGroupId).toBeNull(); + + await deleteTestViewFilterGroupWithRestApi(orGroup.id); + }); + + it('should create a filter group with NOT operator', async () => { + const viewFilterGroupData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'NOT', + }; + + const notGroupResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: viewFilterGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(notGroupResponse, 201); + + const notGroupId = notGroupResponse.body.id; + + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilterGroups/${notGroupId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + }); + + it('should create a nested filter group with parent relationship', async () => { + const parentData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'AND', + }; + + const parentResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: parentData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const parentId = parentResponse.body.id; + + const childData = { + viewId: TEST_VIEW_1_ID, + parentViewFilterGroupId: parentId, + logicalOperator: 'OR', + }; + + const childGroupResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: childData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(childGroupResponse, 201); + }); + }); + + describe('GET /metadata/viewFilterGroups/:id', () => { + it('should return a view filter group by id', async () => { + const viewFilterGroupData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'NOT', + }; + + const createResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: viewFilterGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const viewFilterGroupId = createResponse.body.id; + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilterGroups/${viewFilterGroupId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toBeDefined(); + expect(response.body.id).toBe(viewFilterGroupId); + expect(response.body.viewId).toBe(TEST_VIEW_1_ID); + expect(response.body.logicalOperator).toBe('NOT'); + }); + + it('should return empty object for non-existent view filter group', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilterGroups/${TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual({}); + }); + }); + + describe('PATCH /metadata/viewFilterGroups/:id', () => { + it('should update an existing filter group', async () => { + const viewFilterGroupData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'AND', + }; + + const createResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: viewFilterGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const viewFilterGroupId = createResponse.body.id; + + const updateData = { + logicalOperator: 'OR', + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFilterGroups/${viewFilterGroupId}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toBeDefined(); + expect(response.body.id).toBe(viewFilterGroupId); + expect(response.body.logicalOperator).toBe('OR'); + expect(response.body.viewId).toBe(TEST_VIEW_1_ID); + }); + + it('should update parent relationship of filter group', async () => { + const parentData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'AND', + }; + + const parentResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: parentData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const parentId = parentResponse.body.id; + + const childData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'OR', + }; + + const childResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: childData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const childId = childResponse.body.id; + + const updateData = { + parentViewFilterGroupId: parentId, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFilterGroups/${childId}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toBeDefined(); + expect(response.body.id).toBe(childId); + expect(response.body.parentViewFilterGroupId).toBe(parentId); + expect(response.body.logicalOperator).toBe( + ViewFilterGroupLogicalOperator.OR, + ); + }); + + it('should return 404 error when updating non-existent filter group', async () => { + const updateData = { + logicalOperator: 'AND', + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFilterGroups/${TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewFilterGroupExceptionMessage( + ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + ), + ); + }); + }); + + describe('DELETE /metadata/viewFilterGroups/:id', () => { + it('should delete an existing filter group', async () => { + const viewFilterGroupData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'AND', + }; + + const createResponse = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: viewFilterGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + const viewFilterGroupId = createResponse.body.id; + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilterGroups/${viewFilterGroupId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + expect(deleteResponse.body).toBeDefined(); + expect(deleteResponse.body.success).toBe(true); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilterGroups/${viewFilterGroupId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual({}); + }); + + it('should return 404 error when deleting non-existent filter group', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilterGroups/${TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewFilterGroupExceptionMessage( + ViewFilterGroupExceptionMessageKey.VIEW_FILTER_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_GROUP_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts new file mode 100644 index 0000000000..2ce686afe3 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts @@ -0,0 +1,278 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_FILTER_ID, + TEST_VIEW_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { + createTestViewFilterWithRestApi, + createTestViewWithRestApi, + deleteTestViewFilterWithRestApi, +} from 'test/integration/rest/utils/view-rest-api.util'; +import { + assertViewFilterStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ViewFilterOperand } from 'src/engine/core-modules/view/enums/view-filter-operand'; +import { + generateViewFilterExceptionMessage, + ViewFilterExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-filter.exception'; + +describe('View Filter REST API', () => { + beforeEach(async () => { + await cleanupViewRecords(); + + await createTestViewWithRestApi({ + name: 'Test View for Filters', + }); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('GET /metadata/viewFilters', () => { + it('should return empty array when no view filters exist', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilters?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual([]); + }); + + it('should return all view filters for workspace when no viewId provided', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/viewFilters', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return view filters for a specific view after creating one', async () => { + const viewFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.CONTAINS, + value: 'test', + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilters?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const returnedViewFilter = response.body[0]; + + assertViewFilterStructure(returnedViewFilter, { + id: viewFilter.id, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + operand: ViewFilterOperand.CONTAINS, + value: 'test', + }); + + await deleteTestViewFilterWithRestApi(viewFilter.id); + }); + }); + + describe('POST /metadata/viewFilters', () => { + it('should create a new view filter with string value', async () => { + const viewFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.IS, + value: 'test value', + }); + + assertViewFilterStructure(viewFilter, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + operand: ViewFilterOperand.IS, + value: 'test value', + }); + + await deleteTestViewFilterWithRestApi(viewFilter.id); + }); + + it('should create a view filter with numeric value', async () => { + const numericFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + value: '100', + }); + + assertViewFilterStructure(numericFilter, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + value: '100', + }); + + await deleteTestViewFilterWithRestApi(numericFilter.id); + }); + + it('should create a view filter with boolean value', async () => { + const booleanFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.IS, + value: 'true', + }); + + assertViewFilterStructure(booleanFilter, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + operand: ViewFilterOperand.IS, + value: 'true', + }); + + await deleteTestViewFilterWithRestApi(booleanFilter.id); + }); + }); + + describe('GET /metadata/viewFilters/:id', () => { + it('should return a view filter by id', async () => { + const viewFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.IS, + value: 'test', + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilters/${viewFilter.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewFilterStructure(response.body, { + id: viewFilter.id, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + operand: ViewFilterOperand.IS, + value: 'test', + }); + + await deleteTestViewFilterWithRestApi(viewFilter.id); + }); + + it('should return empty object for non-existent view filter', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilters/${TEST_NOT_EXISTING_VIEW_FILTER_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual({}); + }); + }); + + describe('PATCH /metadata/viewFilters/:id', () => { + it('should update an existing view filter', async () => { + const viewFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.IS, + value: 'original', + }); + + const updateData = { + operand: ViewFilterOperand.IS_NOT, + value: 'updated', + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFilters/${viewFilter.id}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewFilterStructure(response.body, { + id: viewFilter.id, + operand: ViewFilterOperand.IS_NOT, + value: 'updated', + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + }); + + await deleteTestViewFilterWithRestApi(viewFilter.id); + }); + + it('should return 404 error when updating non-existent view filter', async () => { + const updateData = { + operand: ViewFilterOperand.IS_NOT, + value: 'updated', + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewFilters/${TEST_NOT_EXISTING_VIEW_FILTER_ID}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewFilterExceptionMessage( + ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_ID, + ), + ); + }); + }); + + describe('DELETE /metadata/viewFilters/:id', () => { + it('should delete an existing view filter', async () => { + const viewFilter = await createTestViewFilterWithRestApi({ + operand: ViewFilterOperand.IS, + value: 'to delete', + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilters/${viewFilter.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + expect(deleteResponse.body.success).toBe(true); + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewFilters/${viewFilter.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(getResponse); + expect(getResponse.body).toEqual({}); + }); + + it('should return 404 error when deleting non-existent view filter', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilters/${TEST_NOT_EXISTING_VIEW_FILTER_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewFilterExceptionMessage( + ViewFilterExceptionMessageKey.VIEW_FILTER_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_FILTER_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/view-group.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view-group.integration-spec.ts new file mode 100644 index 0000000000..390af36515 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/view-group.integration-spec.ts @@ -0,0 +1,384 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_GROUP_ID, + TEST_VIEW_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { + createTestViewGroupWithRestApi, + createTestViewWithRestApi, + deleteTestViewGroupWithRestApi, +} from 'test/integration/rest/utils/view-rest-api.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { + assertViewGroupStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { + generateViewGroupExceptionMessage, + ViewGroupExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-group.exception'; + +describe('View Group REST API', () => { + beforeEach(async () => { + await cleanupViewRecords(); + + await createTestViewWithRestApi({ + name: generateRecordName('Test View for Groups'), + }); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('GET /metadata/viewGroups', () => { + it('should return empty array when no view groups exist', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewGroups?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual([]); + }); + + it('should return all view groups for workspace when no viewId provided', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/viewGroups', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return view groups for a specific view after creating one', async () => { + const viewGroup = await createTestViewGroupWithRestApi({ + fieldValue: 'test-field-value', + isVisible: true, + position: 0, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewGroups?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const returnedViewGroup = response.body[0]; + + assertViewGroupStructure(returnedViewGroup, { + id: viewGroup.id, + viewId: TEST_VIEW_1_ID, + fieldValue: 'test-field-value', + isVisible: true, + position: 0, + }); + + await deleteTestViewGroupWithRestApi(viewGroup.id); + }); + + it('should return multiple view groups for a view', async () => { + const viewGroup1 = await createTestViewGroupWithRestApi({ + fieldValue: 'group-1', + position: 0, + }); + + const viewGroup2 = await createTestViewGroupWithRestApi({ + fieldValue: 'group-2', + position: 1, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewGroups?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + + const group1 = response.body.find( + (group: ViewGroup) => group.id === viewGroup1.id, + ); + const group2 = response.body.find( + (group: ViewGroup) => group.id === viewGroup2.id, + ); + + assertViewGroupStructure(group1, { + fieldValue: 'group-1', + position: 0, + }); + + assertViewGroupStructure(group2, { + fieldValue: 'group-2', + position: 1, + }); + + await deleteTestViewGroupWithRestApi(viewGroup1.id); + await deleteTestViewGroupWithRestApi(viewGroup2.id); + }); + }); + + describe('GET /metadata/viewGroups/:id', () => { + it('should return a specific view group by id', async () => { + const viewGroup = await createTestViewGroupWithRestApi({ + fieldValue: 'specific-group', + isVisible: false, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewGroups/${viewGroup.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewGroupStructure(response.body, { + id: viewGroup.id, + viewId: TEST_VIEW_1_ID, + fieldValue: 'specific-group', + isVisible: false, + }); + + await deleteTestViewGroupWithRestApi(viewGroup.id); + }); + }); + + describe('POST /metadata/viewGroups', () => { + it('should create a new view group', async () => { + const viewGroupData = { + viewId: TEST_VIEW_1_ID, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + fieldValue: 'new-group-value', + isVisible: true, + position: 5, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewGroups', + body: viewGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response, 201); + assertViewGroupStructure(response.body, { + viewId: TEST_VIEW_1_ID, + fieldValue: 'new-group-value', + isVisible: true, + position: 5, + }); + + await deleteTestViewGroupWithRestApi(response.body.id); + }); + + it('should create view group with minimal required fields', async () => { + const viewGroupData = { + viewId: TEST_VIEW_1_ID, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + fieldValue: 'minimal-group', + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewGroups', + body: viewGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response, 201); + assertViewGroupStructure(response.body, { + viewId: TEST_VIEW_1_ID, + fieldValue: 'minimal-group', + isVisible: true, + position: 0, + }); + + await deleteTestViewGroupWithRestApi(response.body.id); + }); + + it('should fail to create view group with missing required fields', async () => { + const invalidData = { + viewId: TEST_VIEW_1_ID, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewGroups', + body: invalidData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 400, + generateViewGroupExceptionMessage( + ViewGroupExceptionMessageKey.INVALID_VIEW_GROUP_DATA, + ), + ); + }); + }); + + describe('PATCH /metadata/viewGroups/:id', () => { + it('should update an existing view group', async () => { + const viewGroup = await createTestViewGroupWithRestApi({ + fieldValue: 'original-value', + isVisible: true, + position: 1, + }); + + const updateData = { + fieldValue: 'updated-value', + isVisible: false, + position: 2, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewGroups/${viewGroup.id}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewGroupStructure(response.body, { + id: viewGroup.id, + viewId: TEST_VIEW_1_ID, + fieldValue: 'updated-value', + isVisible: false, + position: 2, + }); + + await deleteTestViewGroupWithRestApi(viewGroup.id); + }); + + it('should update only specific fields', async () => { + const viewGroup = await createTestViewGroupWithRestApi({ + fieldValue: 'original-value', + isVisible: true, + position: 1, + }); + + const updateData = { + fieldValue: 'partially-updated', + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewGroups/${viewGroup.id}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewGroupStructure(response.body, { + id: viewGroup.id, + fieldValue: 'partially-updated', + isVisible: true, + position: 1, + }); + + await deleteTestViewGroupWithRestApi(viewGroup.id); + }); + + it('should return 404 for non-existent view group', async () => { + const updateData = { + fieldValue: 'test-update', + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewGroups/${TEST_NOT_EXISTING_VIEW_GROUP_ID}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewGroupExceptionMessage( + ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_GROUP_ID, + ), + ); + }); + }); + + describe('DELETE /metadata/viewGroups/:id', () => { + it('should delete an existing view group', async () => { + const viewGroup = await createTestViewGroupWithRestApi({ + fieldValue: 'to-be-deleted', + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewGroups/${viewGroup.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + expect(deleteResponse.body).toEqual({ success: true }); + }); + + it('should return 404 for non-existent view group', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewGroups/${TEST_NOT_EXISTING_VIEW_GROUP_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewGroupExceptionMessage( + ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_GROUP_ID, + ), + ); + }); + + it('should return success even when group is already deleted', async () => { + const viewGroup = await createTestViewGroupWithRestApi({ + fieldValue: 'double-delete-test', + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewGroups/${viewGroup.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + + const deleteResponse2 = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewGroups/${viewGroup.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + deleteResponse2, + 404, + generateViewGroupExceptionMessage( + ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_GROUP_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/view-sort.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view-sort.integration-spec.ts new file mode 100644 index 0000000000..0c37e5cbaa --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/view-sort.integration-spec.ts @@ -0,0 +1,239 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_NOT_EXISTING_VIEW_SORT_ID, + TEST_VIEW_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { + createTestViewSortWithRestApi, + createTestViewWithRestApi, +} from 'test/integration/rest/utils/view-rest-api.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { + assertViewSortStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ViewSortDirection } from 'src/engine/core-modules/view/enums/view-sort-direction'; +import { + generateViewSortExceptionMessage, + ViewSortExceptionMessageKey, +} from 'src/engine/core-modules/view/exceptions/view-sort.exception'; + +describe('View Sort REST API', () => { + beforeEach(async () => { + await cleanupViewRecords(); + + await createTestViewWithRestApi({ + name: generateRecordName('Test View for Sorts'), + }); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('GET /metadata/viewSorts', () => { + it('should return empty array when no view sorts exist', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewSorts?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual([]); + }); + + it('should return all view sorts for workspace when no viewId provided', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/viewSorts', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return view sorts for a specific view after creating one', async () => { + const viewSort = await createTestViewSortWithRestApi({ + direction: ViewSortDirection.ASC, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewSorts?viewId=${TEST_VIEW_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const returnedViewSort = response.body[0]; + + assertViewSortStructure(returnedViewSort, { + id: viewSort.id, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + direction: ViewSortDirection.ASC, + }); + }); + }); + + describe('POST /metadata/viewSorts', () => { + it('should create a new view sort with ASC direction', async () => { + const viewSort = await createTestViewSortWithRestApi({ + direction: ViewSortDirection.ASC, + }); + + assertViewSortStructure(viewSort, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + direction: ViewSortDirection.ASC, + }); + }); + + it('should create a view sort with DESC direction', async () => { + const descSort = await createTestViewSortWithRestApi({ + direction: ViewSortDirection.DESC, + }); + + assertViewSortStructure(descSort, { + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + direction: ViewSortDirection.DESC, + }); + }); + }); + + describe('GET /metadata/viewSorts/:id', () => { + it('should return a view sort by id', async () => { + const viewSort = await createTestViewSortWithRestApi({ + direction: ViewSortDirection.ASC, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewSorts/${viewSort.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewSortStructure(response.body, { + id: viewSort.id, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + direction: ViewSortDirection.ASC, + }); + }); + + it('should return empty object for non-existent view sort', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewSorts/${TEST_NOT_EXISTING_VIEW_SORT_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual({}); + }); + }); + + describe('PATCH /metadata/viewSorts/:id', () => { + it('should update an existing view sort', async () => { + const viewSort = await createTestViewSortWithRestApi({ + direction: ViewSortDirection.ASC, + }); + + const updateData = { + direction: ViewSortDirection.DESC, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewSorts/${viewSort.id}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewSortStructure(response.body, { + id: viewSort.id, + direction: ViewSortDirection.DESC, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + viewId: TEST_VIEW_1_ID, + }); + }); + + it('should return 404 error when updating non-existent view sort', async () => { + const updateData = { + direction: ViewSortDirection.DESC, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/viewSorts/${TEST_NOT_EXISTING_VIEW_SORT_ID}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewSortExceptionMessage( + ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_SORT_ID, + ), + ); + }); + }); + + describe('DELETE /metadata/viewSorts/:id', () => { + it('should delete an existing view sort', async () => { + const viewSort = await createTestViewSortWithRestApi({ + direction: ViewSortDirection.ASC, + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewSorts/${viewSort.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + expect(deleteResponse.body.success).toBe(true); + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/viewSorts/${viewSort.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(getResponse); + expect(getResponse.body).toEqual({}); + }); + + it('should return 404 error when deleting non-existent view sort', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewSorts/${TEST_NOT_EXISTING_VIEW_SORT_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewSortExceptionMessage( + ViewSortExceptionMessageKey.VIEW_SORT_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_SORT_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/suites/view.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view.integration-spec.ts new file mode 100644 index 0000000000..2ee4ece3a5 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/view.integration-spec.ts @@ -0,0 +1,263 @@ +import { + TEST_NOT_EXISTING_VIEW_ID, + TEST_OBJECT_METADATA_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { + createTestViewWithRestApi, + deleteTestViewWithRestApi, +} from 'test/integration/rest/utils/view-rest-api.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { + assertViewStructure, + cleanupViewRecords, +} from 'test/integration/utils/view-test.util'; + +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; +import { + ViewExceptionMessageKey, + generateViewExceptionMessage, +} from 'src/engine/core-modules/view/exceptions/view.exception'; + +describe('View REST API', () => { + beforeEach(async () => { + await cleanupViewRecords(); + }); + + afterAll(async () => { + await cleanupViewRecords(); + }); + + describe('GET /metadata/views', () => { + it('should return all views for workspace', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/views', + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return views filtered by objectMetadataId', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/views?objectMetadataId=${TEST_OBJECT_METADATA_1_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + + if (response.body.length > 0) { + assertViewStructure(response.body[0]); + } + }); + }); + + describe('POST /metadata/views', () => { + it('should create a new view', async () => { + const viewName = generateRecordName('Test View'); + const view = await createTestViewWithRestApi({ + name: viewName, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + + assertViewStructure(view, { + name: viewName, + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + }); + + it('should create a kanban view', async () => { + const viewName = generateRecordName('Test Kanban View'); + const kanbanView = await createTestViewWithRestApi({ + name: viewName, + icon: 'IconKanban', + type: ViewType.KANBAN, + key: 'KANBAN', + position: 1, + isCompact: true, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + + assertViewStructure(kanbanView, { + name: viewName, + type: ViewType.KANBAN, + isCompact: true, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + + await deleteTestViewWithRestApi(kanbanView.id); + }); + }); + + describe('GET /metadata/views/:id', () => { + it('should return a view by id', async () => { + const viewName = generateRecordName('Test View for Get'); + const view = await createTestViewWithRestApi({ + name: viewName, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/views/${view.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewStructure(response.body, { + id: view.id, + name: viewName, + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + }); + }); + + it('should return empty object for non-existent view', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/views/${TEST_NOT_EXISTING_VIEW_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toEqual({}); + }); + }); + + describe('PATCH /metadata/views/:id', () => { + it('should update an existing view', async () => { + const viewName = generateRecordName('Test View for Update'); + const view = await createTestViewWithRestApi({ + name: viewName, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + + const updatedName = generateRecordName('Updated View'); + const updateData = { + name: updatedName, + type: ViewType.KANBAN, + isCompact: true, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/views/${view.id}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertViewStructure(response.body, { + id: view.id, + name: updatedName, + type: ViewType.KANBAN, + isCompact: true, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + }); + }); + + it('should return 404 error when updating non-existent view', async () => { + const updateData = { + name: 'Updated View', + type: ViewType.KANBAN, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/views/${TEST_NOT_EXISTING_VIEW_ID}`, + body: updateData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_ID, + ), + ); + }); + }); + + describe('DELETE /metadata/views/:id', () => { + it('should delete an existing view', async () => { + const viewName = generateRecordName('Test View for Delete'); + const view = await createTestViewWithRestApi({ + name: viewName, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/views/${view.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + expect(deleteResponse.body.success).toBe(true); + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/views/${view.id}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(getResponse); + expect(getResponse.body).toEqual({}); + }); + + it('should return 404 error when deleting non-existent view', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/views/${TEST_NOT_EXISTING_VIEW_ID}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generateViewExceptionMessage( + ViewExceptionMessageKey.VIEW_NOT_FOUND, + TEST_NOT_EXISTING_VIEW_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/utils/rest-test-assertions.util.ts b/packages/twenty-server/test/integration/rest/utils/rest-test-assertions.util.ts new file mode 100644 index 0000000000..7397ec8458 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/utils/rest-test-assertions.util.ts @@ -0,0 +1,34 @@ +export interface RestResponse> { + status: number; + body: T & { + error?: string; + errors?: string[] | Record[]; + message?: string; + }; +} + +export const assertRestApiSuccessfulResponse = >( + response: RestResponse, + expectedStatus = 200, +) => { + expect(response.status).toBe(expectedStatus); + expect(response.body).toBeDefined(); + + if (response.body.error || response.body.errors) { + throw new Error( + `Expected successful response but got errors: ${JSON.stringify(response.body)}`, + ); + } +}; + +export const assertRestApiErrorResponse = >( + response: RestResponse, + expectedStatus = 400, + expectedErrorMessage?: string, +) => { + expect(response.status).toBe(expectedStatus); + + if (expectedErrorMessage && response.body.message) { + expect(response.body.message).toContain(expectedErrorMessage); + } +}; diff --git a/packages/twenty-server/test/integration/rest/utils/view-rest-api.util.ts b/packages/twenty-server/test/integration/rest/utils/view-rest-api.util.ts new file mode 100644 index 0000000000..340624547d --- /dev/null +++ b/packages/twenty-server/test/integration/rest/utils/view-rest-api.util.ts @@ -0,0 +1,242 @@ +import { + TEST_FIELD_METADATA_1_ID, + TEST_OBJECT_METADATA_1_ID, + TEST_VIEW_1_ID, +} from 'test/integration/constants/test-view-ids.constants'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; + +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; +import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; + +export const createTestViewWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const viewData = { + id: TEST_VIEW_1_ID, + name: generateRecordName('Test View'), + objectMetadataId: TEST_OBJECT_METADATA_1_ID, + icon: 'IconTable', + type: ViewType.TABLE, + key: 'INDEX', + position: 0, + isCompact: false, + openRecordIn: ViewOpenRecordIn.SIDE_PANEL, + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/views', + body: viewData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test view: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const createTestViewFieldWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const viewFieldData = { + viewId: TEST_VIEW_1_ID, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + position: 0, + isVisible: true, + size: 150, + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFields', + body: viewFieldData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test view field: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const createTestViewFilterWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const viewFilterData = { + viewId: TEST_VIEW_1_ID, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + operand: 'Is', + value: 'test-value', + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilters', + body: viewFilterData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test view filter: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const createTestViewSortWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const viewSortData = { + viewId: TEST_VIEW_1_ID, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + direction: 'ASC', + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewSorts', + body: viewSortData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test view sort: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const createTestViewGroupWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const viewGroupData = { + viewId: TEST_VIEW_1_ID, + fieldMetadataId: TEST_FIELD_METADATA_1_ID, + isVisible: true, + fieldValue: 'test-group-value', + position: 0, + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewGroups', + body: viewGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test view group: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const createTestViewFilterGroupWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const viewFilterGroupData = { + viewId: TEST_VIEW_1_ID, + logicalOperator: 'AND', + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/viewFilterGroups', + body: viewFilterGroupData, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test view filter group: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const deleteTestViewWithRestApi = async ( + viewId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/views/${viewId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }).catch(() => {}); +}; + +export const deleteTestViewFieldWithRestApi = async ( + viewFieldId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFields/${viewFieldId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }).catch(() => {}); +}; + +export const deleteTestViewFilterWithRestApi = async ( + viewFilterId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilters/${viewFilterId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }).catch(() => {}); +}; + +export const deleteTestViewSortWithRestApi = async ( + viewSortId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewSorts/${viewSortId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }).catch(() => {}); +}; + +export const deleteTestViewGroupWithRestApi = async ( + viewGroupId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewGroups/${viewGroupId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }).catch(() => {}); +}; + +export const deleteTestViewFilterGroupWithRestApi = async ( + viewFilterGroupId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/viewFilterGroups/${viewFilterGroupId}`, + bearer: APPLE_JANE_ADMIN_ACCESS_TOKEN, + }).catch(() => {}); +}; diff --git a/packages/twenty-server/test/integration/utils/view-test.util.ts b/packages/twenty-server/test/integration/utils/view-test.util.ts new file mode 100644 index 0000000000..977c667b06 --- /dev/null +++ b/packages/twenty-server/test/integration/utils/view-test.util.ts @@ -0,0 +1,115 @@ +import { ViewField } from 'src/engine/core-modules/view/entities/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/core-modules/view/entities/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/core-modules/view/entities/view-filter.entity'; +import { ViewGroup } from 'src/engine/core-modules/view/entities/view-group.entity'; +import { ViewSort } from 'src/engine/core-modules/view/entities/view-sort.entity'; +import { View } from 'src/engine/core-modules/view/entities/view.entity'; +import { ViewFilterGroupLogicalOperator } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity'; + +export const cleanupViewRecords = async (): Promise => { + // @ts-expect-error legacy noImplicitAny + await global.testDataSource.query(`DELETE from "core"."view"`); +}; + +export const assertViewStructure = ( + view: View, + expectedFields?: Partial, +) => { + expect(view).toBeDefined(); + expect(view.id).toBeDefined(); + expect(view.name).toBeDefined(); + expect(view.objectMetadataId).toBeDefined(); + expect(view.workspaceId).toBeDefined(); + expect(view.createdAt).toBeDefined(); + expect(view.updatedAt).toBeDefined(); + + if (expectedFields) { + expect(view).toMatchObject(expectedFields); + } +}; + +export const assertViewFieldStructure = ( + viewField: ViewField, + expectedFields?: Partial, +) => { + expect(viewField).toBeDefined(); + expect(viewField.id).toBeDefined(); + expect(viewField.fieldMetadataId).toBeDefined(); + expect(viewField.viewId).toBeDefined(); + expect(typeof viewField.position).toBe('number'); + expect(typeof viewField.isVisible).toBe('boolean'); + expect(typeof viewField.size).toBe('number'); + + if (expectedFields) { + expect(viewField).toMatchObject(expectedFields); + } +}; + +export const assertViewSortStructure = ( + viewSort: ViewSort, + expectedFields?: Partial, +) => { + expect(viewSort).toBeDefined(); + expect(viewSort.id).toBeDefined(); + expect(viewSort.fieldMetadataId).toBeDefined(); + expect(viewSort.viewId).toBeDefined(); + expect(viewSort.direction).toBeDefined(); + expect(['ASC', 'DESC']).toContain(viewSort.direction); + + if (expectedFields) { + expect(viewSort).toMatchObject(expectedFields); + } +}; + +export const assertViewFilterStructure = ( + viewFilter: ViewFilter, + expectedFields?: Partial, +) => { + expect(viewFilter).toBeDefined(); + expect(viewFilter.id).toBeDefined(); + expect(viewFilter.fieldMetadataId).toBeDefined(); + expect(viewFilter.viewId).toBeDefined(); + expect(viewFilter.operand).toBeDefined(); + expect(viewFilter.value).toBeDefined(); + + if (expectedFields) { + expect(viewFilter).toMatchObject(expectedFields); + } +}; + +export const assertViewGroupStructure = ( + viewGroup: ViewGroup, + expectedFields?: Partial, +) => { + expect(viewGroup).toBeDefined(); + expect(viewGroup.id).toBeDefined(); + expect(viewGroup.fieldMetadataId).toBeDefined(); + expect(viewGroup.viewId).toBeDefined(); + expect(viewGroup.fieldValue).toBeDefined(); + expect(typeof viewGroup.isVisible).toBe('boolean'); + expect(typeof viewGroup.position).toBe('number'); + + if (expectedFields) { + expect(viewGroup).toMatchObject(expectedFields); + } +}; + +export const assertViewFilterGroupStructure = ( + viewFilterGroup: ViewFilterGroup, + expectedFields?: Partial, + validLogicalOperators: string[] = [ + ViewFilterGroupLogicalOperator.AND, + ViewFilterGroupLogicalOperator.OR, + ViewFilterGroupLogicalOperator.NOT, + ], +) => { + expect(viewFilterGroup).toBeDefined(); + expect(viewFilterGroup.id).toBeDefined(); + expect(viewFilterGroup.viewId).toBeDefined(); + expect(viewFilterGroup.logicalOperator).toBeDefined(); + expect(validLogicalOperators).toContain(viewFilterGroup.logicalOperator); + + if (expectedFields) { + expect(viewFilterGroup).toMatchObject(expectedFields); + } +};