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:
@@ -3738,6 +3738,7 @@ input UpsertViewWidgetViewFieldInput {
|
||||
isVisible: Boolean!
|
||||
position: Float!
|
||||
size: Float
|
||||
aggregateOperation: AggregateOperations
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewFilterInput {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -9841,6 +9841,9 @@ export default {
|
||||
"size": [
|
||||
16
|
||||
],
|
||||
"aggregateOperation": [
|
||||
55
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
|
||||
@@ -5894,6 +5894,7 @@ export type UpsertViewWidgetInput = {
|
||||
};
|
||||
|
||||
export type UpsertViewWidgetViewFieldInput = {
|
||||
aggregateOperation?: InputMaybe<AggregateOperations>;
|
||||
/** The field metadata id. Used to create a new view field when viewFieldId is not provided. */
|
||||
fieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
isVisible: Scalars['Boolean']['input'];
|
||||
|
||||
+66
-41
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type RecordTableWidgetContextValue = {
|
||||
isPageLayoutInEditMode: boolean;
|
||||
pageLayoutId?: string;
|
||||
widgetId: string;
|
||||
};
|
||||
|
||||
export const RecordTableWidgetContext =
|
||||
createContext<RecordTableWidgetContextValue | null>(null);
|
||||
+31
@@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
@@ -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))
|
||||
|
||||
+54
-6
@@ -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,
|
||||
|
||||
+5
-25
@@ -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) => {
|
||||
|
||||
+57
@@ -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 };
|
||||
};
|
||||
+13
-1
@@ -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;
|
||||
}
|
||||
|
||||
+11
-2
@@ -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,
|
||||
|
||||
+137
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user