diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f0efc1d2bc..7865106f1e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -876,6 +876,7 @@ export type CoreViewField = { position: Scalars['Float']; size: Scalars['Float']; updatedAt: Scalars['DateTime']; + viewFieldGroupId?: Maybe; viewId: Scalars['UUID']; workspaceId: Scalars['UUID']; }; @@ -2353,6 +2354,7 @@ export type Mutation = { uploadWorkspaceMemberProfilePicture: FileWithSignedUrl; uploadWorkspaceMemberProfilePictureLegacy: SignedFile; upsertFieldPermissions: Array; + upsertFieldsWidget: CoreView; upsertObjectPermissions: Array; upsertPermissionFlags: Array; upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult; @@ -3207,6 +3209,11 @@ export type MutationUpsertFieldPermissionsArgs = { }; +export type MutationUpsertFieldsWidgetArgs = { + input: UpsertFieldsWidgetInput; +}; + + export type MutationUpsertObjectPermissionsArgs = { upsertObjectPermissionsInput: UpsertObjectPermissionsInput; }; @@ -4856,6 +4863,30 @@ export type UpsertFieldPermissionsInput = { roleId: Scalars['UUID']; }; +export type UpsertFieldsWidgetFieldInput = { + isVisible: Scalars['Boolean']; + position: Scalars['Float']; + /** The id of the view field */ + viewFieldId: Scalars['UUID']; +}; + +export type UpsertFieldsWidgetGroupInput = { + fields: Array; + id: Scalars['UUID']; + isVisible: Scalars['Boolean']; + name: Scalars['String']; + position: Scalars['Float']; +}; + +export type UpsertFieldsWidgetInput = { + /** The ungrouped fields to upsert. When provided, all existing groups are deleted and fields are detached from groups. Mutually exclusive with "groups". */ + fields?: InputMaybe>; + /** The groups (with nested fields) to upsert. Mutually exclusive with "fields". */ + groups?: InputMaybe>; + /** The id of the fields widget whose groups and fields to upsert */ + widgetId: Scalars['UUID']; +}; + export type UpsertObjectPermissionsInput = { objectPermissions: Array; roleId: Scalars['UUID']; @@ -6006,6 +6037,13 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{ export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: string, name: string, type: PageLayoutType, objectMetadataId?: string | null, defaultTabToFocusOnMobileAndSidePanelId?: string | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: string, applicationId: string, title: string, icon?: string | null, position: number, layoutMode?: PageLayoutTabLayoutMode | null, pageLayoutId: string, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: string, title: string, type: WidgetType, objectMetadataId?: string | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: string, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, position?: { __typename?: 'PageLayoutWidgetCanvasPosition', layoutMode: PageLayoutTabLayoutMode } | { __typename?: 'PageLayoutWidgetGridPosition', layoutMode: PageLayoutTabLayoutMode, row: number, column: number, rowSpan: number, columnSpan: number } | { __typename?: 'PageLayoutWidgetVerticalListPosition', layoutMode: PageLayoutTabLayoutMode, index: number } | null, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: string, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'CalendarConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'EmailsConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldRichTextConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldsConfiguration', configurationType: WidgetConfigurationType, viewId?: string | null } | { __typename?: 'FilesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FrontComponentConfiguration', configurationType: WidgetConfigurationType, frontComponentId: string } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'NotesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: string, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, hideEmptyCategory?: boolean | null, splitMultiValueFields?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | { __typename?: 'TasksConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'TimelineConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'ViewConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowRunConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowVersionConfiguration', configurationType: WidgetConfigurationType } }> | null }> | null } }; +export type UpsertFieldsWidgetMutationVariables = Exact<{ + input: UpsertFieldsWidgetInput; +}>; + + +export type UpsertFieldsWidgetMutation = { __typename?: 'Mutation', upsertFieldsWidget: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFieldGroups: Array<{ __typename?: 'CoreViewFieldGroup', id: string, name: string, position: number, isVisible: boolean, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }> }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } }; + export type FindAllRecordPageLayoutsQueryVariables = Exact<{ [key: string]: never; }>; @@ -11677,6 +11715,39 @@ export function useUpdatePageLayoutWithTabsAndWidgetsMutation(baseOptions?: Apol export type UpdatePageLayoutWithTabsAndWidgetsMutationHookResult = ReturnType; export type UpdatePageLayoutWithTabsAndWidgetsMutationResult = Apollo.MutationResult; export type UpdatePageLayoutWithTabsAndWidgetsMutationOptions = Apollo.BaseMutationOptions; +export const UpsertFieldsWidgetDocument = gql` + mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) { + upsertFieldsWidget(input: $input) { + ...ViewFragment + } +} + ${ViewFragmentFragmentDoc}`; +export type UpsertFieldsWidgetMutationFn = Apollo.MutationFunction; + +/** + * __useUpsertFieldsWidgetMutation__ + * + * To run a mutation, you first call `useUpsertFieldsWidgetMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpsertFieldsWidgetMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [upsertFieldsWidgetMutation, { data, loading, error }] = useUpsertFieldsWidgetMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpsertFieldsWidgetMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpsertFieldsWidgetDocument, options); + } +export type UpsertFieldsWidgetMutationHookResult = ReturnType; +export type UpsertFieldsWidgetMutationResult = Apollo.MutationResult; +export type UpsertFieldsWidgetMutationOptions = Apollo.BaseMutationOptions; export const FindAllRecordPageLayoutsDocument = gql` query FindAllRecordPageLayouts { getPageLayouts(pageLayoutType: RECORD_PAGE) { diff --git a/packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts b/packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts new file mode 100644 index 0000000000..af4ef2ab5a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts @@ -0,0 +1,11 @@ +import { VIEW_FRAGMENT } from '@/views/graphql/fragments/viewFragment'; +import { gql } from '@apollo/client'; + +export const UPSERT_FIELDS_WIDGET = gql` + ${VIEW_FRAGMENT} + mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) { + upsertFieldsWidget(input: $input) { + ...ViewFragment + } + } +`; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts index c951b18f33..e032c34f54 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts @@ -1,18 +1,36 @@ +import { UPSERT_FIELDS_WIDGET } from '@/page-layout/graphql/mutations/upsertFieldsWidget'; import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState'; -import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState'; -import { computeFieldsWidgetFieldDiff } from '@/page-layout/utils/computeFieldsWidgetFieldDiff'; -import { computeFieldsWidgetGroupDiff } from '@/page-layout/utils/computeFieldsWidgetGroupDiff'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; -import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist'; -import { usePerformViewFieldGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFieldGroupAPIPersist'; import { useRefreshAllCoreViews } from '@/views/hooks/useRefreshAllCoreViews'; +import { useMutation } from '@apollo/client'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; -import { - type FieldsConfiguration, - WidgetConfigurationType, -} from '~/generated-metadata/graphql'; +import { type ViewFragmentFragment } from '~/generated-metadata/graphql'; + +type UpsertFieldsWidgetInput = { + widgetId: string; + groups?: { + id: string; + name: string; + position: number; + isVisible: boolean; + fields: { + viewFieldId: string; + isVisible: boolean; + position: number; + }[]; + }[]; + fields?: { + viewFieldId: string; + isVisible: boolean; + position: number; + }[]; +}; + +type UpsertFieldsWidgetResult = { + upsertFieldsWidget: ViewFragmentFragment; +}; type UseSaveFieldsWidgetGroupsParams = { pageLayoutId: string; @@ -31,52 +49,13 @@ export const useSaveFieldsWidgetGroups = ({ pageLayoutId, ); - const pageLayoutPersistedState = useRecoilComponentCallbackState( - pageLayoutPersistedComponentState, - pageLayoutId, - ); - - const { - performViewFieldGroupAPICreate, - performViewFieldGroupAPIUpdate, - performViewFieldGroupAPIDelete, - } = usePerformViewFieldGroupAPIPersist(); - - const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist(); + const [upsertFieldsWidgetMutation] = useMutation< + UpsertFieldsWidgetResult, + { input: UpsertFieldsWidgetInput } + >(UPSERT_FIELDS_WIDGET); const { refreshAllCoreViews } = useRefreshAllCoreViews(); - const getViewIdForWidget = useRecoilCallback( - ({ snapshot }) => - (widgetId: string): string | null => { - const pageLayoutPersisted = snapshot - .getLoadable(pageLayoutPersistedState) - .getValue(); - - if (!isDefined(pageLayoutPersisted)) { - return null; - } - - for (const tab of pageLayoutPersisted.tabs) { - for (const widget of tab.widgets) { - if ( - widget.id === widgetId && - isDefined(widget.configuration) && - widget.configuration.configurationType === - WidgetConfigurationType.FIELDS - ) { - return ( - (widget.configuration as FieldsConfiguration).viewId ?? null - ); - } - } - } - - return null; - }, - [pageLayoutPersistedState], - ); - const saveFieldsWidgetGroups = useRecoilCallback( ({ set, snapshot }) => async () => { @@ -94,73 +73,33 @@ export const useSaveFieldsWidgetGroups = ({ for (const widgetId of widgetIds) { const draftGroups = allDraftGroups[widgetId] ?? []; - const persistedGroups = allPersistedGroups[widgetId] ?? []; - if (draftGroups.length === 0 && persistedGroups.length === 0) { - continue; - } - - const viewId = getViewIdForWidget(widgetId); - - if (!isDefined(viewId)) { - continue; - } - - const { createdGroups, deletedGroups, updatedGroups } = - computeFieldsWidgetGroupDiff(persistedGroups, draftGroups); - - if (createdGroups.length > 0) { - await performViewFieldGroupAPICreate({ - inputs: createdGroups.map((group) => ({ - id: group.id, - name: group.name, - position: group.position, - isVisible: group.isVisible, - viewId, - })), - }); - } - - if (deletedGroups.length > 0) { - for (const group of deletedGroups) { - await performViewFieldGroupAPIDelete([ - { input: { id: group.id } }, - ]); - } - } - - if (updatedGroups.length > 0) { - const updates = updatedGroups.map((group) => ({ + await upsertFieldsWidgetMutation({ + variables: { input: { - id: group.id, - update: { + widgetId, + groups: draftGroups.map((group) => ({ + id: group.id, name: group.name, position: group.position, isVisible: group.isVisible, - }, + fields: group.fields.flatMap((field) => { + if (!isDefined(field.viewFieldId)) { + return []; + } + + return [ + { + viewFieldId: field.viewFieldId, + isVisible: field.isVisible, + position: field.position, + }, + ]; + }), + })), }, - })); - - await performViewFieldGroupAPIUpdate(updates); - } - - const fieldUpdates = computeFieldsWidgetFieldDiff( - persistedGroups, - draftGroups, - ); - - if (fieldUpdates.length > 0) { - const viewFieldUpdateInputs = fieldUpdates.map( - ({ viewFieldId, ...updates }) => ({ - input: { - id: viewFieldId, - update: updates, - }, - }), - ); - - await performViewFieldAPIUpdate(viewFieldUpdateInputs); - } + }, + }); } set(fieldsWidgetGroupsPersistedState, allDraftGroups); @@ -172,11 +111,7 @@ export const useSaveFieldsWidgetGroups = ({ [ fieldsWidgetGroupsDraftState, fieldsWidgetGroupsPersistedState, - getViewIdForWidget, - performViewFieldGroupAPICreate, - performViewFieldGroupAPIDelete, - performViewFieldGroupAPIUpdate, - performViewFieldAPIUpdate, + upsertFieldsWidgetMutation, refreshAllCoreViews, ], ); diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts new file mode 100644 index 0000000000..4c258f68c0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts @@ -0,0 +1,21 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsBoolean, IsNotEmpty, IsNumber, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class UpsertFieldsWidgetFieldInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType, { description: 'The id of the view field' }) + viewFieldId: string; + + @IsBoolean() + @Field() + isVisible: boolean; + + @IsNumber() + @Field() + position: number; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts new file mode 100644 index 0000000000..01651f0881 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts @@ -0,0 +1,40 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input'; + +@InputType() +export class UpsertFieldsWidgetGroupInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType) + id: string; + + @IsString() + @IsNotEmpty() + @Field() + name: string; + + @IsNumber() + @Field() + position: number; + + @IsBoolean() + @Field() + isVisible: boolean; + + @ValidateNested({ each: true }) + @Type(() => UpsertFieldsWidgetFieldInput) + @Field(() => [UpsertFieldsWidgetFieldInput]) + fields: UpsertFieldsWidgetFieldInput[]; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts new file mode 100644 index 0000000000..e022f9e835 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts @@ -0,0 +1,44 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { + IsNotEmpty, + IsOptional, + IsUUID, + ValidateNested, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input'; +import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input'; + +@InputType() +export class UpsertFieldsWidgetInput { + @IsUUID() + @IsNotEmpty() + @Field(() => UUIDScalarType, { + description: + 'The id of the fields widget whose groups and fields to upsert', + }) + widgetId: string; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => UpsertFieldsWidgetGroupInput) + @Field(() => [UpsertFieldsWidgetGroupInput], { + nullable: true, + description: + 'The groups (with nested fields) to upsert. Mutually exclusive with "fields".', + }) + groups?: UpsertFieldsWidgetGroupInput[]; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => UpsertFieldsWidgetFieldInput) + @Field(() => [UpsertFieldsWidgetFieldInput], { + nullable: true, + description: + 'The ungrouped fields to upsert. When provided, all existing groups are deleted and fields are detached from groups. Mutually exclusive with "groups".', + }) + fields?: UpsertFieldsWidgetFieldInput[]; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts index d49eb40120..26b62288d1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts @@ -19,5 +19,6 @@ export class ViewFieldGroupException extends CustomException ViewFieldGroupDTO) @UseFilters(ViewGraphqlApiExceptionFilter) @UseGuards(WorkspaceAuthGuard) export class ViewFieldGroupResolver { - constructor(private readonly viewFieldGroupService: ViewFieldGroupService) {} + constructor( + private readonly viewFieldGroupService: ViewFieldGroupService, + private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService, + ) {} @Query(() => [ViewFieldGroupDTO]) @UseGuards(NoPermissionGuard) @@ -113,6 +120,18 @@ export class ViewFieldGroupResolver { }); } + @Mutation(() => ViewDTO) + @UseGuards(NoPermissionGuard) + async upsertFieldsWidget( + @Args('input') input: UpsertFieldsWidgetInput, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ): Promise { + return await this.fieldsWidgetUpsertService.upsertFieldsWidget({ + input, + workspaceId, + }); + } + @ResolveField(() => [ViewFieldDTO]) async viewFields( @Parent() viewFieldGroup: ViewFieldGroupDTO, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts new file mode 100644 index 0000000000..51928fe646 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts @@ -0,0 +1,444 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; +import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util'; +import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type'; +import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type'; +import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type'; +import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type'; +import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { type UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input'; +import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input'; +import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input'; +import { + ViewFieldGroupException, + ViewFieldGroupExceptionCode, +} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception'; +import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +@Injectable() +export class FieldsWidgetUpsertService { + constructor( + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly applicationService: ApplicationService, + @InjectRepository(ViewEntity) + private readonly viewRepository: Repository, + ) {} + + async upsertFieldsWidget({ + input, + workspaceId, + }: { + input: UpsertFieldsWidgetInput; + workspaceId: string; + }): Promise { + const hasGroups = isDefined(input.groups); + const hasFields = isDefined(input.fields); + + if (hasGroups === hasFields) { + throw new ViewFieldGroupException( + t`Exactly one of "groups" or "fields" must be provided`, + ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA, + ); + } + + const { widgetId } = input; + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const { + flatPageLayoutWidgetMaps, + flatViewFieldGroupMaps, + flatViewFieldMaps, + flatViewMaps, + } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatPageLayoutWidgetMaps', + 'flatViewFieldGroupMaps', + 'flatViewFieldMaps', + 'flatViewMaps', + ], + }, + ); + + const widget = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: widgetId, + flatEntityMaps: flatPageLayoutWidgetMaps, + }); + + if ( + !isDefined(widget) || + !isFlatPageLayoutWidgetConfigurationOfType( + widget, + WidgetConfigurationType.FIELDS, + ) + ) { + throw new ViewFieldGroupException( + t`Fields widget not found`, + ViewFieldGroupExceptionCode.FIELDS_WIDGET_NOT_FOUND, + ); + } + + const viewId = widget.configuration.viewId; + + if (!isDefined(viewId)) { + throw new ViewFieldGroupException( + t`Fields widget has no associated view`, + ViewFieldGroupExceptionCode.VIEW_NOT_FOUND, + ); + } + + const existingGroups = Object.values( + flatViewFieldGroupMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter( + (group) => !isDefined(group.deletedAt) && group.viewId === viewId, + ); + + const existingViewFields = Object.values( + flatViewFieldMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter( + (field) => !isDefined(field.deletedAt) && field.viewId === viewId, + ); + + if (hasGroups) { + await this.upsertFieldsWidgetWithGroups({ + inputGroups: input.groups!, + existingGroups, + existingViewFields, + viewId, + workspaceId, + applicationId: workspaceCustomFlatApplication.id, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + flatViewMaps, + flatViewFieldGroupMaps, + }); + } else { + await this.upsertFieldsWidgetWithFields({ + inputFields: input.fields!, + existingGroups, + existingViewFields, + workspaceId, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + }); + } + + const view = await this.viewRepository.findOne({ + where: { id: viewId, workspaceId, deletedAt: IsNull() }, + }); + + if (!isDefined(view)) { + throw new ViewFieldGroupException( + t`View not found after upsert`, + ViewFieldGroupExceptionCode.VIEW_NOT_FOUND, + ); + } + + return view; + } + + private async upsertFieldsWidgetWithGroups({ + inputGroups, + existingGroups, + existingViewFields, + viewId, + workspaceId, + applicationId, + applicationUniversalIdentifier, + flatViewMaps, + flatViewFieldGroupMaps, + }: { + inputGroups: UpsertFieldsWidgetGroupInput[]; + existingGroups: FlatViewFieldGroup[]; + existingViewFields: FlatViewField[]; + viewId: string; + workspaceId: string; + applicationId: string; + applicationUniversalIdentifier: string; + flatViewMaps: FlatViewMaps; + flatViewFieldGroupMaps: FlatViewFieldGroupMaps; + }): Promise { + const now = new Date().toISOString(); + const inputGroupIds = new Set(inputGroups.map((g) => g.id)); + + const groupsToCreate: FlatViewFieldGroup[] = []; + const groupsToUpdate: FlatViewFieldGroup[] = []; + const groupsToDelete: FlatViewFieldGroup[] = []; + + for (const inputGroup of inputGroups) { + const existingGroup = existingGroups.find((g) => g.id === inputGroup.id); + + if (!isDefined(existingGroup)) { + groupsToCreate.push( + this.buildGroupToCreate({ + inputGroup, + viewId, + workspaceId, + applicationId, + applicationUniversalIdentifier, + now, + flatViewMaps, + }), + ); + } else if (this.hasGroupChanged(existingGroup, inputGroup)) { + groupsToUpdate.push({ + ...existingGroup, + name: inputGroup.name, + position: inputGroup.position, + isVisible: inputGroup.isVisible, + updatedAt: now, + }); + } + } + + for (const existingGroup of existingGroups) { + if (!inputGroupIds.has(existingGroup.id)) { + groupsToDelete.push(existingGroup); + } + } + + // Build optimistic maps so that newly created groups can be resolved when + // computing viewFieldGroupUniversalIdentifier for view field updates. + const optimisticFlatViewFieldGroupMaps: FlatViewFieldGroupMaps = + groupsToCreate.reduce( + (maps, group) => + addFlatEntityToFlatEntityMapsOrThrow({ + flatEntity: group, + flatEntityMaps: maps, + }), + flatViewFieldGroupMaps, + ); + + const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => { + const inputGroup = inputGroups.find((g) => + g.fields.some((f) => f.viewFieldId === existingField.id), + ); + + if (!isDefined(inputGroup)) { + return []; + } + + const inputField = inputGroup.fields.find( + (f) => f.viewFieldId === existingField.id, + ); + + if (!isDefined(inputField)) { + return []; + } + + const newViewFieldGroupId = inputGroup.id; + + const hasChanged = + existingField.isVisible !== inputField.isVisible || + existingField.position !== inputField.position || + existingField.viewFieldGroupId !== newViewFieldGroupId; + + if (!hasChanged) { + return []; + } + + const { viewFieldGroupUniversalIdentifier } = + resolveEntityRelationUniversalIdentifiers({ + metadataName: 'viewField', + foreignKeyValues: { + viewFieldGroupId: newViewFieldGroupId, + }, + flatEntityMaps: { + flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps, + }, + }); + + return [ + { + ...existingField, + isVisible: inputField.isVisible, + position: inputField.position, + viewFieldGroupId: newViewFieldGroupId, + viewFieldGroupUniversalIdentifier, + updatedAt: now, + }, + ]; + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + viewFieldGroup: { + flatEntityToCreate: groupsToCreate, + flatEntityToDelete: groupsToDelete, + flatEntityToUpdate: groupsToUpdate, + }, + viewField: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: viewFieldsToUpdate, + }, + }, + workspaceId, + isSystemBuild: false, + applicationUniversalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while upserting fields widget', + ); + } + } + + private async upsertFieldsWidgetWithFields({ + inputFields, + existingGroups, + existingViewFields, + workspaceId, + applicationUniversalIdentifier, + }: { + inputFields: UpsertFieldsWidgetFieldInput[]; + existingGroups: FlatViewFieldGroup[]; + existingViewFields: FlatViewField[]; + workspaceId: string; + applicationUniversalIdentifier: string; + }): Promise { + const now = new Date().toISOString(); + + const groupsToDelete: FlatViewFieldGroup[] = [...existingGroups]; + + const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => { + const inputField = inputFields.find( + (f) => f.viewFieldId === existingField.id, + ); + + if (!isDefined(inputField)) { + return []; + } + + const hasChanged = + existingField.isVisible !== inputField.isVisible || + existingField.position !== inputField.position || + existingField.viewFieldGroupId !== null; + + if (!hasChanged) { + return []; + } + + return [ + { + ...existingField, + isVisible: inputField.isVisible, + position: inputField.position, + viewFieldGroupId: null, + viewFieldGroupUniversalIdentifier: null, + updatedAt: now, + }, + ]; + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + viewFieldGroup: { + flatEntityToCreate: [], + flatEntityToDelete: groupsToDelete, + flatEntityToUpdate: [], + }, + viewField: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: viewFieldsToUpdate, + }, + }, + workspaceId, + isSystemBuild: false, + applicationUniversalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while upserting fields widget', + ); + } + } + + private buildGroupToCreate({ + inputGroup, + viewId, + workspaceId, + applicationId, + applicationUniversalIdentifier, + now, + flatViewMaps, + }: { + inputGroup: UpsertFieldsWidgetGroupInput; + viewId: string; + workspaceId: string; + applicationId: string; + applicationUniversalIdentifier: string; + now: string; + flatViewMaps: FlatViewMaps; + }): FlatViewFieldGroup { + const { viewUniversalIdentifier } = + resolveEntityRelationUniversalIdentifiers({ + metadataName: 'viewFieldGroup', + foreignKeyValues: { viewId }, + flatEntityMaps: { flatViewMaps }, + }); + + return { + id: inputGroup.id, + workspaceId, + applicationId, + universalIdentifier: inputGroup.id, + applicationUniversalIdentifier, + name: inputGroup.name, + position: inputGroup.position, + isVisible: inputGroup.isVisible, + viewId, + viewUniversalIdentifier, + createdAt: now, + updatedAt: now, + deletedAt: null, + viewFieldIds: [], + viewFieldUniversalIdentifiers: [], + }; + } + + private hasGroupChanged( + existing: FlatViewFieldGroup, + input: UpsertFieldsWidgetGroupInput, + ): boolean { + return ( + existing.name !== input.name || + existing.position !== input.position || + existing.isVisible !== input.isVisible + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts index efe6c3b46d..f3eb6ac7ed 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts @@ -6,6 +6,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity'; import { ViewFieldGroupResolver } from 'src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver'; +import { FieldsWidgetUpsertService } from 'src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service'; import { ViewFieldGroupService } from 'src/engine/metadata-modules/view-field-group/services/view-field-group.service'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -20,7 +21,11 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceMigrationModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, ], - providers: [ViewFieldGroupResolver, ViewFieldGroupService], + providers: [ + ViewFieldGroupResolver, + ViewFieldGroupService, + FieldsWidgetUpsertService, + ], exports: [ViewFieldGroupService], }) export class ViewFieldGroupModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts index ea0097d7ef..cac03c6011 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts @@ -30,6 +30,9 @@ export class ViewFieldDTO { @Field(() => UUIDScalarType, { nullable: false }) viewId: string; + @Field(() => UUIDScalarType, { nullable: true }) + viewFieldGroupId?: string | null; + @Field(() => UUIDScalarType, { nullable: false }) workspaceId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts index f5c740fd48..107c77ef4d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts @@ -87,6 +87,8 @@ export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => { throw new NotFoundError(error.message); case ViewFieldGroupExceptionCode.VIEW_NOT_FOUND: throw new NotFoundError(error.message); + case ViewFieldGroupExceptionCode.FIELDS_WIDGET_NOT_FOUND: + throw new NotFoundError(error.message); case ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA: throw new UserInputError(error.message, { userFriendlyMessage: error.userFriendlyMessage, 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 index 01778ace56..6c0c292688 100644 --- a/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts +++ b/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts @@ -23,6 +23,7 @@ export const VIEW_FIELD_GQL_FIELDS = ` isVisible size viewId + viewFieldGroupId createdAt updatedAt deletedAt diff --git a/packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts new file mode 100644 index 0000000000..27dbc7a941 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts @@ -0,0 +1,504 @@ +import { + VIEW_FIELD_GQL_FIELDS, + VIEW_FIELD_GROUP_GQL_FIELDS, + VIEW_GQL_FIELDS, +} from 'test/integration/constants/view-gql-fields.constants'; +import { findCoreViewFieldGroups } from 'test/integration/metadata/suites/view-field-group/utils/find-core-view-field-groups.util'; +import { upsertFieldsWidget } from 'test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util'; +import { findCoreViewFields } from 'test/integration/metadata/suites/view-field/utils/find-core-view-fields.util'; +import { v4 as uuidv4 } from 'uuid'; + +type FieldsWidgetTestSetup = { + widgetId: string; + viewId: string; + viewFields: Array<{ + id: string; + fieldMetadataId: string; + position: number; + isVisible: boolean; + viewFieldGroupId: string | null; + }>; +}; + +const VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS = ` + ${VIEW_GQL_FIELDS} + viewFields { + ${VIEW_FIELD_GQL_FIELDS} + } + viewFieldGroups { + ${VIEW_FIELD_GROUP_GQL_FIELDS} + } +`; + +const fetchFieldsWidgetTestSetup = async (): Promise => { + const widgets = await global.testDataSource.query( + `SELECT id, configuration->>'viewId' AS "viewId" + FROM core."pageLayoutWidget" + WHERE type = 'FIELDS' + AND "deletedAt" IS NULL + LIMIT 1`, + ); + + expect(widgets.length).toBeGreaterThan(0); + + const { id: widgetId, viewId } = widgets[0]; + + expect(widgetId).toBeDefined(); + expect(viewId).toBeDefined(); + + const { data } = await findCoreViewFields({ + viewId, + gqlFields: 'id fieldMetadataId position isVisible viewFieldGroupId', + expectToFail: false, + }); + + const viewFields = data.getCoreViewFields; + + return { widgetId, viewId, viewFields }; +}; + +describe('upsertFieldsWidget', () => { + let testSetup: FieldsWidgetTestSetup; + + beforeAll(async () => { + testSetup = await fetchFieldsWidgetTestSetup(); + }); + + describe('with groups input', () => { + it('should upsert fields widget with a new group and return a view', async () => { + const newGroupId = uuidv4(); + const targetFields = testSetup.viewFields.slice(0, 2); + + const { data, errors } = await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: newGroupId, + name: 'Test Group', + position: 0, + isVisible: true, + fields: targetFields.map((f, index) => ({ + viewFieldId: f.id, + isVisible: true, + position: index, + })), + }, + ], + }, + gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS, + }); + + expect(errors).toBeUndefined(); + expect(data.upsertFieldsWidget).toBeDefined(); + expect(data.upsertFieldsWidget.id).toBeDefined(); + expect(data.upsertFieldsWidget.name).toBeDefined(); + + const { data: groupsData } = await findCoreViewFieldGroups({ + viewId: testSetup.viewId, + gqlFields: 'id name', + expectToFail: false, + }); + + const createdGroup = groupsData.getCoreViewFieldGroups.find( + (g: { id: string }) => g.id === newGroupId, + ); + + expect(createdGroup).toBeDefined(); + expect(createdGroup!.name).toBe('Test Group'); + }); + + it('should hard-delete groups not included in the input', async () => { + // First, create a group via upsert + const groupToDeleteId = uuidv4(); + const groupToKeepId = uuidv4(); + + const twoFields = testSetup.viewFields.slice(0, 2); + + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: groupToDeleteId, + name: 'Group To Delete', + position: 0, + isVisible: true, + fields: [ + { + viewFieldId: twoFields[0].id, + isVisible: true, + position: 0, + }, + ], + }, + { + id: groupToKeepId, + name: 'Group To Keep', + position: 1, + isVisible: true, + fields: [ + { + viewFieldId: twoFields[1].id, + isVisible: true, + position: 0, + }, + ], + }, + ], + }, + }); + + // Now upsert again without the first group + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: groupToKeepId, + name: 'Group To Keep', + position: 0, + isVisible: true, + fields: [ + { + viewFieldId: twoFields[1].id, + isVisible: true, + position: 0, + }, + ], + }, + ], + }, + }); + + // Verify the omitted group was hard-deleted (row should be completely gone) + const deletedGroup = await global.testDataSource.query( + `SELECT id FROM core."viewFieldGroup" + WHERE id = $1`, + [groupToDeleteId], + ); + + expect(deletedGroup.length).toBe(0); + + // Verify the kept group is still active + const { data: keptGroupData } = await findCoreViewFieldGroups({ + viewId: testSetup.viewId, + gqlFields: 'id', + expectToFail: false, + }); + + const keptGroup = keptGroupData.getCoreViewFieldGroups.find( + (g: { id: string }) => g.id === groupToKeepId, + ); + + expect(keptGroup).toBeDefined(); + }); + + it('should update view field positions and visibility within groups', async () => { + const groupId = uuidv4(); + const targetField = testSetup.viewFields[0]; + + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: groupId, + name: 'Position Test Group', + position: 0, + isVisible: true, + fields: [ + { + viewFieldId: targetField.id, + isVisible: false, + position: 42, + }, + ], + }, + ], + }, + }); + + // Verify the view field was updated + const { data: fieldsData } = await findCoreViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id isVisible position viewFieldGroupId', + expectToFail: false, + }); + + const updatedField = fieldsData.getCoreViewFields.find( + (f: { id: string }) => f.id === targetField.id, + ); + + expect(updatedField).toBeDefined(); + expect(updatedField!.isVisible).toBe(false); + expect(updatedField!.position).toBe(42); + expect(updatedField!.viewFieldGroupId).toBe(groupId); + }); + }); + + describe('with fields input (ungrouped)', () => { + it('should upsert fields widget with flat fields and return a view', async () => { + const targetFields = testSetup.viewFields.slice(0, 3); + + const { data, errors } = await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + fields: targetFields.map((f, index) => ({ + viewFieldId: f.id, + isVisible: true, + position: index, + })), + }, + gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS, + }); + + expect(errors).toBeUndefined(); + expect(data.upsertFieldsWidget).toBeDefined(); + expect(data.upsertFieldsWidget.id).toBeDefined(); + }); + + it('should hard-delete all existing groups when using flat fields', async () => { + // First create a group via upsert + const groupId = uuidv4(); + const targetField = testSetup.viewFields[0]; + + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: groupId, + name: 'Group To Be Deleted', + position: 0, + isVisible: true, + fields: [ + { + viewFieldId: targetField.id, + isVisible: true, + position: 0, + }, + ], + }, + ], + }, + }); + + // Verify group exists + const { data: groupBeforeData } = await findCoreViewFieldGroups({ + viewId: testSetup.viewId, + gqlFields: 'id', + expectToFail: false, + }); + + const groupBefore = groupBeforeData.getCoreViewFieldGroups.find( + (g: { id: string }) => g.id === groupId, + ); + + expect(groupBefore).toBeDefined(); + + // Now upsert with flat fields + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + fields: [ + { + viewFieldId: targetField.id, + isVisible: true, + position: 0, + }, + ], + }, + }); + + // Verify all groups are soft-deleted + const { data: activeGroupsData } = await findCoreViewFieldGroups({ + viewId: testSetup.viewId, + gqlFields: 'id', + expectToFail: false, + }); + + expect(activeGroupsData.getCoreViewFieldGroups.length).toBe(0); + + // Verify the field's viewFieldGroupId is null + const { data: updatedFieldData } = await findCoreViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id viewFieldGroupId', + expectToFail: false, + }); + + const updatedField = updatedFieldData.getCoreViewFields.find( + (f: { id: string }) => f.id === targetField.id, + ); + + expect(updatedField).toBeDefined(); + expect(updatedField!.viewFieldGroupId).toBeNull(); + }); + + it('should update field positions and visibility without groups', async () => { + const targetField = testSetup.viewFields[0]; + const groupId = uuidv4(); + + // First assign the field to a group so it has a non-null viewFieldGroupId + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: groupId, + name: 'Temporary Group', + position: 0, + isVisible: true, + fields: [ + { + viewFieldId: targetField.id, + isVisible: true, + position: 0, + }, + ], + }, + ], + }, + }); + + // Now switch to flat fields with different position and visibility + await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + fields: [ + { + viewFieldId: targetField.id, + isVisible: false, + position: 99, + }, + ], + }, + }); + + const { data: updatedFieldData } = await findCoreViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id isVisible position viewFieldGroupId', + expectToFail: false, + }); + + const updatedField = updatedFieldData.getCoreViewFields.find( + (f: { id: string }) => f.id === targetField.id, + ); + + expect(updatedField).toBeDefined(); + expect(updatedField!.isVisible).toBe(false); + expect(updatedField!.position).toBe(99); + expect(updatedField!.viewFieldGroupId).toBeNull(); + }); + }); + + describe('validation', () => { + it('should fail when both groups and fields are provided', async () => { + const targetField = testSetup.viewFields[0]; + + const { errors } = await upsertFieldsWidget({ + expectToFail: true, + input: { + widgetId: testSetup.widgetId, + groups: [ + { + id: uuidv4(), + name: 'Test', + position: 0, + isVisible: true, + fields: [ + { + viewFieldId: targetField.id, + isVisible: true, + position: 0, + }, + ], + }, + ], + fields: [ + { + viewFieldId: targetField.id, + isVisible: true, + position: 0, + }, + ], + }, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should fail when neither groups nor fields are provided', async () => { + const { errors } = await upsertFieldsWidget({ + expectToFail: true, + input: { + widgetId: testSetup.widgetId, + }, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should fail when widget id does not exist', async () => { + const { errors } = await upsertFieldsWidget({ + expectToFail: true, + input: { + widgetId: uuidv4(), + fields: [ + { + viewFieldId: testSetup.viewFields[0].id, + isVisible: true, + position: 0, + }, + ], + }, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + describe('return type', () => { + it('should return a view with the expected fields', async () => { + const targetField = testSetup.viewFields[0]; + + const { data } = await upsertFieldsWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + fields: [ + { + viewFieldId: targetField.id, + isVisible: true, + position: 0, + }, + ], + }, + gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS, + }); + + const view = data.upsertFieldsWidget; + + expect(view.id).toBeDefined(); + expect(view.name).toBeDefined(); + expect(view.objectMetadataId).toBeDefined(); + expect(view.workspaceId).toBeDefined(); + expect(view.createdAt).toBeDefined(); + expect(view.updatedAt).toBeDefined(); + expect(Array.isArray(view.viewFields)).toBe(true); + expect(Array.isArray(view.viewFieldGroups)).toBe(true); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts new file mode 100644 index 0000000000..15e7c4b43c --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { type UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input'; + +export const upsertFieldsWidgetQueryFactory = ({ + gqlFields = VIEW_GQL_FIELDS, + input, +}: { + gqlFields?: string; + input: UpsertFieldsWidgetInput; +}) => ({ + query: gql` + mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) { + upsertFieldsWidget(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts new file mode 100644 index 0000000000..d3dd105b02 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts @@ -0,0 +1,43 @@ +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { upsertFieldsWidgetQueryFactory } from 'test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input'; +import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; + +export const upsertFieldsWidget = async ({ + input, + gqlFields, + expectToFail, +}: { + input: UpsertFieldsWidgetInput; + gqlFields?: string; + expectToFail?: boolean | null; +}): CommonResponseBody<{ + upsertFieldsWidget: ViewDTO; +}> => { + const graphqlOperation = upsertFieldsWidgetQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Upsert fields widget should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Upsert fields widget has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};