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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23008?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Scarab Systems
2026-07-23 02:07:50 -04:00
committed by GitHub
parent 32041ce2e0
commit d1c70ab0bf
15 changed files with 392 additions and 76 deletions
@@ -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 (
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: isNonEmptyString(instanceIdSuffix)
? `record-table-widget-${widgetId}-${instanceIdSuffix}`
: `record-table-widget-${widgetId}`,
}}
>
<RecordTableWidgetContextStoreInitEffect
objectMetadataItemId={objectMetadataItem.id}
viewId={viewId}
contextStoreViewType={contextStoreViewType}
/>
<RecordIndexContextProvider
<RecordTableWidgetContext.Provider value={recordTableWidgetContext}>
<ContextStoreComponentInstanceContext.Provider
value={{
objectPermissionsByObjectMetadataId,
recordIndexId,
viewBarInstanceId: recordIndexId,
objectNamePlural: objectMetadataItem.namePlural,
objectNameSingular,
objectMetadataItem,
onIndexRecordsLoaded: handleIndexRecordsLoaded,
indexIdentifierUrl,
recordFieldByFieldMetadataItemId,
labelIdentifierFieldMetadataItem,
fieldMetadataItemByFieldMetadataItemId,
fieldDefinitionByFieldMetadataItemId,
recordLimit,
instanceId: isNonEmptyString(instanceIdSuffix)
? `record-table-widget-${widgetId}-${instanceIdSuffix}`
: `record-table-widget-${widgetId}`,
}}
>
<ViewComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
<RecordTableWidgetContextStoreInitEffect
objectMetadataItemId={objectMetadataItem.id}
viewId={viewId}
contextStoreViewType={contextStoreViewType}
/>
<RecordIndexContextProvider
value={{
objectPermissionsByObjectMetadataId,
recordIndexId,
viewBarInstanceId: recordIndexId,
objectNamePlural: objectMetadataItem.namePlural,
objectNameSingular,
objectMetadataItem,
onIndexRecordsLoaded: handleIndexRecordsLoaded,
indexIdentifierUrl,
recordFieldByFieldMetadataItemId,
labelIdentifierFieldMetadataItem,
fieldMetadataItemByFieldMetadataItemId,
fieldDefinitionByFieldMetadataItemId,
recordLimit,
}}
>
<RecordComponentInstanceContextsWrapper
componentInstanceId={recordIndexId}
<ViewComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<RecordTableWidgetViewLoadEffect
viewId={viewId}
widgetId={widgetId}
objectMetadataItem={objectMetadataItem}
/>
{children}
</RecordComponentInstanceContextsWrapper>
</ViewComponentInstanceContext.Provider>
</RecordIndexContextProvider>
</ContextStoreComponentInstanceContext.Provider>
<RecordComponentInstanceContextsWrapper
componentInstanceId={recordIndexId}
>
<RecordTableWidgetViewLoadEffect
viewId={viewId}
widgetId={widgetId}
objectMetadataItem={objectMetadataItem}
/>
{children}
</RecordComponentInstanceContextsWrapper>
</ViewComponentInstanceContext.Provider>
</RecordIndexContextProvider>
</ContextStoreComponentInstanceContext.Provider>
</RecordTableWidgetContext.Provider>
);
};
@@ -0,0 +1,10 @@
import { createContext } from 'react';
export type RecordTableWidgetContextValue = {
isPageLayoutInEditMode: boolean;
pageLayoutId?: string;
widgetId: string;
};
export const RecordTableWidgetContext =
createContext<RecordTableWidgetContextValue | null>(null);
@@ -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)),
);
});
});
@@ -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))
@@ -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,
},
},
},
@@ -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,
@@ -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) => {
@@ -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 };
};