diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 85019daa19..7791ce6948 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3738,6 +3738,7 @@ input UpsertViewWidgetViewFieldInput { isVisible: Boolean! position: Float! size: Float + aggregateOperation: AggregateOperations } input UpsertViewWidgetViewFilterInput { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 3cbb2e5d96..e8c415d03f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -6387,7 +6387,7 @@ export interface UpsertViewWidgetViewFieldInput { /** The id of an existing view field to update. */ viewFieldId?: (Scalars['UUID'] | null), /** The field metadata id. Used to create a new view field when viewFieldId is not provided. */ -fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null)} +fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null),aggregateOperation?: (AggregateOperations | null)} export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null)} diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 05f466f274..be1d742bd8 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -9841,6 +9841,9 @@ export default { "size": [ 16 ], + "aggregateOperation": [ + 55 + ], "__typename": [ 1 ] diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index ade4d9fa83..de976ed2ba 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -5894,6 +5894,7 @@ export type UpsertViewWidgetInput = { }; export type UpsertViewWidgetViewFieldInput = { + aggregateOperation?: InputMaybe; /** The field metadata id. Used to create a new view field when viewFieldId is not provided. */ fieldMetadataId?: InputMaybe; isVisible: Scalars['Boolean']['input']; diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx index b5d5a78a2a..aa317661b8 100644 --- a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx @@ -8,10 +8,14 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates'; import { RecordTableWidgetContextStoreInitEffect } from '@/object-record/record-table-widget/components/RecordTableWidgetContextStoreInitEffect'; import { RecordTableWidgetViewLoadEffect } from '@/object-record/record-table-widget/components/RecordTableWidgetViewLoadEffect'; +import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext'; +import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { isNonEmptyString } from '@sniptt/guards'; -import { type PropsWithChildren, useCallback } from 'react'; +import { type PropsWithChildren, useCallback, useMemo } from 'react'; import { AppPath } from 'twenty-shared/types'; import { getAppPath } from 'twenty-shared/utils'; @@ -75,55 +79,76 @@ export const RecordTableWidgetProvider = ({ const handleIndexRecordsLoaded = useCallback(() => {}, []); + const pageLayoutComponentInstanceContext = useComponentInstanceStateContext( + PageLayoutComponentInstanceContext, + ); + + const isPageLayoutInEditMode = useIsPageLayoutInEditMode(); + + const recordTableWidgetContext = useMemo( + () => ({ + isPageLayoutInEditMode, + pageLayoutId: pageLayoutComponentInstanceContext?.instanceId, + widgetId, + }), + [ + isPageLayoutInEditMode, + pageLayoutComponentInstanceContext?.instanceId, + widgetId, + ], + ); + if (!objectPermissions.canReadObjectRecords) { return null; } return ( - - - + - + - - - {children} - - - - + + + {children} + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/contexts/RecordTableWidgetContext.ts b/packages/twenty-front/src/modules/object-record/record-table-widget/contexts/RecordTableWidgetContext.ts new file mode 100644 index 0000000000..f73eef86a3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/contexts/RecordTableWidgetContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +export type RecordTableWidgetContextValue = { + isPageLayoutInEditMode: boolean; + pageLayoutId?: string; + widgetId: string; +}; + +export const RecordTableWidgetContext = + createContext(null); diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/utils/__tests__/computeRecordTableWidgetViewLoadContentSignature.test.ts b/packages/twenty-front/src/modules/object-record/record-table-widget/utils/__tests__/computeRecordTableWidgetViewLoadContentSignature.test.ts new file mode 100644 index 0000000000..6085c5aff0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/utils/__tests__/computeRecordTableWidgetViewLoadContentSignature.test.ts @@ -0,0 +1,31 @@ +import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; +import { computeRecordTableWidgetViewLoadContentSignature } from '@/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature'; +import { type View } from '@/views/types/View'; + +const makeView = (aggregateOperation: AggregateOperations | null): View => + ({ + viewFields: [ + { + id: 'view-field-id', + fieldMetadataId: 'field-metadata-id', + isVisible: true, + position: 0, + aggregateOperation, + }, + ], + viewFilters: [], + viewFilterGroups: [], + viewSorts: [], + }) as unknown as View; + +describe('computeRecordTableWidgetViewLoadContentSignature', () => { + it('should include aggregate operations in the record table widget view load signature', () => { + expect( + computeRecordTableWidgetViewLoadContentSignature( + makeView(AggregateOperations.SUM), + ), + ).not.toEqual( + computeRecordTableWidgetViewLoadContentSignature(makeView(null)), + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature.ts b/packages/twenty-front/src/modules/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature.ts index c3a2a23ae5..90839e11cd 100644 --- a/packages/twenty-front/src/modules/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature.ts +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/utils/computeRecordTableWidgetViewLoadContentSignature.ts @@ -11,6 +11,7 @@ export const computeRecordTableWidgetViewLoadContentSignature = ( fieldMetadataId: field.fieldMetadataId, isVisible: field.isVisible, position: field.position, + aggregateOperation: field.aggregateOperation, })), filters: [...view.viewFilters] .sort((filterA, filterB) => filterA.id.localeCompare(filterB.id)) diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation.tsx index d7ad0fd1f8..bcc0d33e97 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useViewFieldAggregateOperation.tsx @@ -2,29 +2,81 @@ import { RecordTableColumnAggregateFooterDropdownContext } from '@/object-record import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState'; import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation'; +import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext'; +import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { useRecordTableWidgetFieldUpdate } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldUpdate'; +import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; +const MISSING_RECORD_TABLE_WIDGET_PAGE_LAYOUT_ID = + '__missing_record_table_widget_page_layout__'; + export const useViewFieldAggregateOperation = () => { const { fieldMetadataId } = useContext( RecordTableColumnAggregateFooterDropdownContext, ); const { currentView } = useGetCurrentViewOnly(); + const recordTableWidgetContext = useContext(RecordTableWidgetContext); - const currentViewField = currentView?.viewFields?.find( + const recordTableWidgetViewDraft = useAtomComponentStateValue( + recordTableWidgetViewDraftComponentState, + recordTableWidgetContext?.pageLayoutId ?? + MISSING_RECORD_TABLE_WIDGET_PAGE_LAYOUT_ID, + ); + + const draftSnapshot = !isDefined(recordTableWidgetContext) + ? undefined + : recordTableWidgetViewDraft[recordTableWidgetContext.widgetId]; + + const shouldUseRecordTableWidgetDraft = + isDefined(recordTableWidgetContext) && + recordTableWidgetContext.isPageLayoutInEditMode && + isDefined(draftSnapshot); + + const currentViewForAggregateOperation = shouldUseRecordTableWidgetDraft + ? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot) + : currentView; + + const currentViewField = currentViewForAggregateOperation?.viewFields?.find( (viewField) => viewField.fieldMetadataId === fieldMetadataId, ); const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist(); + + const { handleFieldUpdated: handleRecordTableWidgetFieldUpdated } = + useRecordTableWidgetFieldUpdate({ + pageLayoutId: + recordTableWidgetContext?.pageLayoutId ?? + MISSING_RECORD_TABLE_WIDGET_PAGE_LAYOUT_ID, + widgetId: recordTableWidgetContext?.widgetId ?? '', + }); + const updateViewFieldAggregateOperation = async ( aggregateOperation: ExtendedAggregateOperations | null, ) => { if (!currentViewField) { throw new Error('ViewField not found'); } + + const aggregateOperationForPersistence = + aggregateOperation === null + ? null + : convertExtendedAggregateOperationToAggregateOperation( + aggregateOperation, + ); + + if (shouldUseRecordTableWidgetDraft) { + handleRecordTableWidgetFieldUpdated(currentViewField.id, { + aggregateOperation: aggregateOperationForPersistence, + }); + return; + } + await performViewFieldAPIUpdate([ { input: { @@ -33,11 +85,7 @@ export const useViewFieldAggregateOperation = () => { isVisible: currentViewField.isVisible, position: currentViewField.position, size: currentViewField.size, - aggregateOperation: isDefined(aggregateOperation) - ? convertExtendedAggregateOperationToAggregateOperation( - aggregateOperation, - ) - : null, + aggregateOperation: aggregateOperationForPersistence, }, }, }, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts index f8042d82fd..cd9014d161 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts @@ -115,6 +115,7 @@ export const useSaveRecordTableWidgetViews = () => { isVisible: field.isVisible, position: field.position, size: field.size, + aggregateOperation: field.aggregateOperation ?? null, })), viewFilters: widgetViewDraft.viewFilters.map((filter) => ({ id: filter.id, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks.ts index c90edcf026..f98d399113 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks.ts @@ -1,5 +1,6 @@ import { type RecordField } from '@/object-record/record-field/types/RecordField'; import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { useRecordTableWidgetFieldUpdate } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldUpdate'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { isDefined } from 'twenty-shared/utils'; @@ -22,31 +23,10 @@ export const useRecordTableWidgetFieldCallbacks = ({ const store = useStore(); - const handleFieldUpdated = ( - viewFieldId: string, - update: Partial<{ position: number; isVisible: boolean }>, - ) => { - store.set(recordTableWidgetViewDraftState, (prev) => { - const widgetViewDraft = prev[widgetId]; - - if (!isDefined(widgetViewDraft)) { - return prev; - } - - return { - ...prev, - [widgetId]: { - ...widgetViewDraft, - viewFields: widgetViewDraft.viewFields.map((field) => - field.id === viewFieldId || - field.clientRecordFieldId === viewFieldId - ? { ...field, ...update } - : field, - ), - }, - }; - }); - }; + const { handleFieldUpdated } = useRecordTableWidgetFieldUpdate({ + pageLayoutId, + widgetId, + }); const handleFieldCreated = (recordField: RecordField) => { store.set(recordTableWidgetViewDraftState, (prev) => { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldUpdate.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldUpdate.ts new file mode 100644 index 0000000000..6b3d12e578 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldUpdate.ts @@ -0,0 +1,57 @@ +import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState'; +import { type RecordTableWidgetDraftViewField } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; + +type UseRecordTableWidgetFieldUpdateParams = { + pageLayoutId: string; + widgetId: string; +}; + +type RecordTableWidgetFieldUpdate = Partial< + Pick< + RecordTableWidgetDraftViewField, + 'aggregateOperation' | 'isVisible' | 'position' + > +>; + +export const useRecordTableWidgetFieldUpdate = ({ + pageLayoutId, + widgetId, +}: UseRecordTableWidgetFieldUpdateParams) => { + const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState( + recordTableWidgetViewDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const handleFieldUpdated = ( + viewFieldId: string, + update: RecordTableWidgetFieldUpdate, + ) => { + store.set(recordTableWidgetViewDraftState, (prev) => { + const widgetViewDraft = prev[widgetId]; + + if (!isDefined(widgetViewDraft)) { + return prev; + } + + return { + ...prev, + [widgetId]: { + ...widgetViewDraft, + viewFields: widgetViewDraft.viewFields.map((field) => + field.id === viewFieldId || + field.clientRecordFieldId === viewFieldId + ? { ...field, ...update } + : field, + ), + }, + }; + }); + }; + + return { handleFieldUpdated }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input.ts index e8a1f9af43..90fe2bde02 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input.ts @@ -1,6 +1,13 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsUUID, +} from 'class-validator'; +import { AggregateOperations } from 'twenty-shared/types'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { AtLeastOneOf } from 'src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator'; @@ -37,4 +44,9 @@ export class UpsertViewWidgetViewFieldInput { @IsNumber() @Field({ nullable: true }) size?: number; + + @IsOptional() + @IsEnum(AggregateOperations) + @Field(() => AggregateOperations, { nullable: true }) + aggregateOperation?: AggregateOperations | null; } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts index 9020006e84..c8fa0672e2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts @@ -489,11 +489,17 @@ export class ViewWidgetUpsertService { const resolvedSize = isDefined(existingField.overrides?.size) ? existingField.overrides.size : existingField.size; + const resolvedAggregateOperation = + existingField.overrides?.aggregateOperation !== undefined + ? existingField.overrides.aggregateOperation + : existingField.aggregateOperation; const hasChanged = resolvedIsVisible !== inputField.isVisible || resolvedPosition !== inputField.position || - (isDefined(inputField.size) && resolvedSize !== inputField.size); + (isDefined(inputField.size) && resolvedSize !== inputField.size) || + (inputField.aggregateOperation !== undefined && + resolvedAggregateOperation !== inputField.aggregateOperation); if (!hasChanged) { continue; @@ -516,6 +522,9 @@ export class ViewWidgetUpsertService { isVisible: inputField.isVisible, position: inputField.position, ...(isDefined(inputField.size) ? { size: inputField.size } : {}), + ...(inputField.aggregateOperation !== undefined + ? { aggregateOperation: inputField.aggregateOperation } + : {}), }, shouldOverride, }); @@ -582,7 +591,7 @@ export class ViewWidgetUpsertService { isVisible: inputField.isVisible, size: inputField.size ?? DEFAULT_VIEW_FIELD_SIZE, position: inputField.position, - aggregateOperation: null, + aggregateOperation: inputField.aggregateOperation ?? null, overrides: null, universalOverrides: null, isActive: true, diff --git a/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts index 58e73b75fd..07c74ee74d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts @@ -21,6 +21,7 @@ import { createOneView } from 'test/integration/metadata/suites/view/utils/creat import { destroyOneView } from 'test/integration/metadata/suites/view/utils/destroy-one-view.util'; import { upsertViewWidget } from 'test/integration/metadata/suites/view/utils/upsert-view-widget.util'; import { + AggregateOperations, ViewFilterGroupLogicalOperator, ViewFilterOperand, ViewSortDirection, @@ -303,6 +304,142 @@ describe('upsertViewWidget', () => { expect(updatedField!.position).toBe(42); }); + it('should create a new view field with an aggregate operation', async () => { + const { data: fieldsBefore } = await findViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id fieldMetadataId position isVisible aggregateOperation', + expectToFail: false, + }); + + const existingFieldMetadataIds = new Set( + fieldsBefore.getViewFields.map( + (field: { fieldMetadataId: string }) => field.fieldMetadataId, + ), + ); + const newFieldMetadataId = testSetup.fieldMetadataIds.find( + (id) => !existingFieldMetadataIds.has(id), + ); + + expect(newFieldMetadataId).toBeDefined(); + + await upsertViewWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + viewFields: [ + ...fieldsBefore.getViewFields.map( + (field: { + id: string; + fieldMetadataId: string; + isVisible: boolean; + position: number; + aggregateOperation?: AggregateOperations | null; + }) => ({ + viewFieldId: field.id, + fieldMetadataId: field.fieldMetadataId, + isVisible: field.isVisible, + position: field.position, + aggregateOperation: field.aggregateOperation ?? null, + }), + ), + { + fieldMetadataId: newFieldMetadataId!, + isVisible: true, + position: 99, + aggregateOperation: AggregateOperations.SUM, + }, + ], + }, + }); + + const { data: fieldsAfter } = await findViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id fieldMetadataId aggregateOperation', + expectToFail: false, + }); + + const createdField = fieldsAfter.getViewFields.find( + (field: { fieldMetadataId: string }) => + field.fieldMetadataId === newFieldMetadataId, + ); + + expect(createdField).toBeDefined(); + expect(createdField!.aggregateOperation).toBe(AggregateOperations.SUM); + }); + + it('should update and clear an existing view field aggregate operation', async () => { + const { data: fieldsBefore } = await findViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id fieldMetadataId position isVisible aggregateOperation', + expectToFail: false, + }); + + const targetField = fieldsBefore.getViewFields.find( + (field: { fieldMetadataId: string }) => + field.fieldMetadataId !== testSetup.labelIdentifierFieldMetadataId, + ); + + expect(targetField).toBeDefined(); + + await upsertViewWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + viewFields: [ + { + viewFieldId: targetField!.id, + fieldMetadataId: targetField!.fieldMetadataId, + isVisible: targetField!.isVisible, + position: targetField!.position, + aggregateOperation: AggregateOperations.COUNT, + }, + ], + }, + }); + + const { data: fieldsWithAggregate } = await findViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id aggregateOperation', + expectToFail: false, + }); + + const updatedField = fieldsWithAggregate.getViewFields.find( + (field: { id: string }) => field.id === targetField!.id, + ); + + expect(updatedField).toBeDefined(); + expect(updatedField!.aggregateOperation).toBe(AggregateOperations.COUNT); + + await upsertViewWidget({ + expectToFail: false, + input: { + widgetId: testSetup.widgetId, + viewFields: [ + { + viewFieldId: targetField!.id, + fieldMetadataId: targetField!.fieldMetadataId, + isVisible: targetField!.isVisible, + position: targetField!.position, + aggregateOperation: null, + }, + ], + }, + }); + + const { data: fieldsAfterClear } = await findViewFields({ + viewId: testSetup.viewId, + gqlFields: 'id aggregateOperation', + expectToFail: false, + }); + + const clearedField = fieldsAfterClear.getViewFields.find( + (field: { id: string }) => field.id === targetField!.id, + ); + + expect(clearedField).toBeDefined(); + expect(clearedField!.aggregateOperation).toBeNull(); + }); + it('should not modify fields when viewFields is omitted', async () => { const { data: fieldsBefore } = await findViewFields({ viewId: testSetup.viewId,