From d1c70ab0bf64312b105a437c522c93bc67327f8f Mon Sep 17 00:00:00 2001 From: Scarab Systems Date: Thu, 23 Jul 2026 02:07:50 -0400 Subject: [PATCH] Fix dashboard record table widget aggregate persistence (#23008) ## Summary - Update dashboard record-table widget aggregate changes to write into the widget draft while page layout edit mode is active. - Include `aggregateOperation` when saving record-table widget view fields through `upsertViewWidget`. - Persist aggregate operations server-side for widget view-field create, update, and clear flows. - Add frontend utility tests and backend integration coverage for widget aggregate create/update/clear behavior. Fixes #22934. ## Why Dashboard record-table widgets use their own draft view state while a page layout is being edited. The aggregate footer path was resolving fields through the normal current-view flow and then trying to persist immediately, which can miss widget draft fields and fail before the save flow runs. This change keeps aggregate edits in the widget draft during page layout editing, then saves the aggregate operation with the rest of the widget view configuration. ## Validation - `npx nx lint twenty-front` - `npx nx typecheck twenty-front` - `npx nx test twenty-front --configuration=ci` - `npx nx build twenty-front` - `npx nx build twenty-server` - `npx nx lint twenty-server --configuration=ci` - `npx nx typecheck twenty-server` - `npx nx test twenty-server --configuration=ci` - `npx nx jest --config ./jest-integration.config.ts --logHeapUsage --runTestsByPath test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts` - `git diff --check` Disclosure: I used AI-assisted coding tools while preparing this PR. I reviewed the changes myself, tested them, and take responsibility for the implementation and any follow-up revisions needed. Review in cubic --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 +- .../src/metadata/generated/types.ts | 3 + .../src/generated-metadata/graphql.ts | 1 + .../components/RecordTableWidgetProvider.tsx | 107 ++++++++------ .../contexts/RecordTableWidgetContext.ts | 10 ++ ...ableWidgetViewLoadContentSignature.test.ts | 31 ++++ ...cordTableWidgetViewLoadContentSignature.ts | 1 + .../hooks/useViewFieldAggregateOperation.tsx | 60 +++++++- .../hooks/useSaveRecordTableWidgetViews.ts | 1 + .../useRecordTableWidgetFieldCallbacks.ts | 30 +--- .../hooks/useRecordTableWidgetFieldUpdate.ts | 57 ++++++++ .../upsert-view-widget-view-field.input.ts | 14 +- .../services/view-widget-upsert.service.ts | 13 +- .../upsert-view-widget.integration-spec.ts | 137 ++++++++++++++++++ 15 files changed, 392 insertions(+), 76 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-table-widget/contexts/RecordTableWidgetContext.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table-widget/utils/__tests__/computeRecordTableWidgetViewLoadContentSignature.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldUpdate.ts 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,