diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx index d35f65aa4b..174f5b48b1 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx @@ -22,6 +22,7 @@ import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { ViewType } from '@/views/types/ViewType'; import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy'; import { useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; @@ -85,7 +86,8 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { const { availableFieldsForGrouping } = useGetAvailableFieldsToGroupRecordsBy(); - const hasOnlyOneGroupByOption = availableFieldsForGrouping.length <= 1; + const isGroupByFieldPickerDisabled = + availableFieldsForGrouping.length <= 1 && viewType !== ViewType.TABLE; const isRelationGroupBy = isDefined(recordIndexGroupFieldMetadataItem) && @@ -135,13 +137,13 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { - !hasOnlyOneGroupByOption && + !isGroupByFieldPickerDisabled && onContentChange('recordGroupFields') } > onContentChange('recordGroupFields')} LeftIcon={IconLayoutList} text={t`Group by`} diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts index 8370d47413..a9ff3944c0 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -2,17 +2,13 @@ import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; -import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { usePerformViewAPIUpdate } from '@/views/hooks/internal/usePerformViewAPIUpdate'; import { useGetViewFromState } from '@/views/hooks/useGetViewFromState'; -import { type ViewGroup } from '@/views/types/ViewGroup'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; -import { v4 } from 'uuid'; -import { type View as GqlView } from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const useHandleRecordGroupField = () => { @@ -24,13 +20,41 @@ export const useHandleRecordGroupField = () => { const { getViewFromState } = useGetViewFromState(); - const { setRecordGroupsFromViewGroups } = useSetRecordGroups(); - const { performViewAPIUpdate } = usePerformViewAPIUpdate(); const { loadRecordIndexStates } = useLoadRecordIndexStates(); const store = useStore(); + const updateViewMainGroupByFieldMetadataId = useCallback( + async ({ + viewId, + mainGroupByFieldMetadataId, + }: { + viewId: string; + mainGroupByFieldMetadataId: string | null; + }) => { + const updatedViewResult = await performViewAPIUpdate({ + id: viewId, + input: { + mainGroupByFieldMetadataId, + }, + }); + + if (updatedViewResult.status !== 'successful') { + return; + } + + const updatedView = updatedViewResult.response.data?.updateView; + + if (!isDefined(updatedView)) { + return; + } + + loadRecordIndexStates(updatedView, objectMetadataItem); + }, + [performViewAPIUpdate, loadRecordIndexStates, objectMetadataItem], + ); + const handleRecordGroupFieldChange = useCallback( async (fieldMetadataItem: FieldMetadataItem) => { const currentViewId = store.get(currentViewIdCallbackState); @@ -55,78 +79,15 @@ export const useHandleRecordGroupField = () => { return; } - const updatedViewResult = await performViewAPIUpdate({ - id: view.id, - input: { - mainGroupByFieldMetadataId: fieldMetadataItem.id, - }, - }); - - if (updatedViewResult.status === 'successful') { - const updatedView = updatedViewResult.response.data - ?.updateView as GqlView; - - if (isDefined(updatedView)) { - await loadRecordIndexStates(updatedView, objectMetadataItem); - } - } - - const existingGroupKeys = new Set( - view.viewGroups.map( - (group) => `${view.mainGroupByFieldMetadataId}:${group.fieldValue}`, - ), - ); - - const viewGroupsToCreate = ( - isRelationGroupBy ? [] : (fieldMetadataItem.options ?? []) - ) - .filter( - (option) => - !existingGroupKeys.has(`${fieldMetadataItem.id}:${option.value}`), - ) - .sort((a, b) => a.value.localeCompare(b.value)) - .map( - (option, index) => - ({ - id: v4(), - fieldValue: option.value, - isVisible: true, - position: index, - }) satisfies ViewGroup, - ); - - if ( - !existingGroupKeys.has(`${fieldMetadataItem.id}:`) && - fieldMetadataItem.isNullable === true - ) { - viewGroupsToCreate.push({ - id: v4(), - fieldValue: '', - isVisible: true, - position: viewGroupsToCreate.length, - } satisfies ViewGroup); - } - - const isSameField = - view.mainGroupByFieldMetadataId === fieldMetadataItem.id; - const keptGroups = isSameField ? view.viewGroups : []; - - const newViewGroupsList = [...keptGroups, ...viewGroupsToCreate]; - - setRecordGroupsFromViewGroups({ + await updateViewMainGroupByFieldMetadataId({ viewId: view.id, mainGroupByFieldMetadataId: fieldMetadataItem.id, - viewGroups: newViewGroupsList, - objectMetadataItem, }); }, [ currentViewIdCallbackState, getViewFromState, - performViewAPIUpdate, - setRecordGroupsFromViewGroups, - objectMetadataItem, - loadRecordIndexStates, + updateViewMainGroupByFieldMetadataId, store, ], ); @@ -144,20 +105,18 @@ export const useHandleRecordGroupField = () => { return; } - if (view.viewGroups.length === 0) { + if (!isDefined(view.mainGroupByFieldMetadataId)) { return; } - await performViewAPIUpdate({ - id: view.id, - input: { - mainGroupByFieldMetadataId: null, - }, + await updateViewMainGroupByFieldMetadataId({ + viewId: view.id, + mainGroupByFieldMetadataId: null, }); }, [ currentViewIdCallbackState, getViewFromState, - performViewAPIUpdate, + updateViewMainGroupByFieldMetadataId, store, ]); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts index 7cf21ca61e..015144ffb7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts @@ -79,4 +79,26 @@ describe('buildDraftViewGroupsForFieldMetadataItem', () => { expect(viewGroups).toHaveLength(0); }); + + it('should build only the empty group for nullable many-to-one relation fields', () => { + const viewGroups = buildDraftViewGroupsForFieldMetadataItem({ + viewId: VIEW_ID, + fieldMetadataItem: { + ...baseFieldMetadataItem, + isNullable: true, + type: FieldMetadataType.RELATION, + relation: { + type: RelationType.MANY_TO_ONE, + } as FieldMetadataItem['relation'], + }, + }); + + expect(viewGroups).toHaveLength(1); + expect(viewGroups[0]).toMatchObject({ + viewId: VIEW_ID, + fieldValue: '', + position: 0, + isVisible: true, + }); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts index 3fe5d23873..9f1841c7ef 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts @@ -4,10 +4,10 @@ import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRel import { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from 'twenty-shared/constants'; import { v4 } from 'uuid'; -// Mirrors the server-side computeFlatViewGroupsOnViewCreate so the edit-mode -// draft preview matches what the server generates on save: one group per -// select option (in option order) plus an empty group for nullable fields. -// Relation group-by generates no option groups. +// Mirrors the server-side computeFlatViewGroupsOnViewCreate so the edit-mode draft +// preview matches what the server generates on save: one group per select option (in +// option order), none for relations since their groups are picked by the user, plus +// an empty group for nullable fields. export const buildDraftViewGroupsForFieldMetadataItem = ({ viewId, fieldMetadataItem, @@ -15,29 +15,34 @@ export const buildDraftViewGroupsForFieldMetadataItem = ({ viewId: string; fieldMetadataItem: FieldMetadataItem; }): FlatViewGroup[] => { - if (isManyToOneRelationField(fieldMetadataItem)) { - return []; + const selectOptions = isManyToOneRelationField(fieldMetadataItem) + ? [] + : (fieldMetadataItem.options ?? []); + + const viewGroupsFromOptions: FlatViewGroup[] = selectOptions.map( + (option, index) => ({ + id: v4(), + viewId, + fieldValue: option.value, + position: index, + isVisible: index < VIEW_GROUP_VISIBLE_OPTIONS_MAX, + }), + ); + + if (fieldMetadataItem.isNullable !== true) { + return viewGroupsFromOptions; } - const viewGroupsFromOptions: FlatViewGroup[] = ( - fieldMetadataItem.options ?? [] - ).map((option, index) => ({ - id: v4(), - viewId, - fieldValue: option.value, - position: index, - isVisible: index < VIEW_GROUP_VISIBLE_OPTIONS_MAX, - })); + const emptyViewGroupPosition = viewGroupsFromOptions.length; - if (fieldMetadataItem.isNullable === true) { - viewGroupsFromOptions.push({ + return [ + ...viewGroupsFromOptions, + { id: v4(), viewId, fieldValue: '', - position: viewGroupsFromOptions.length, - isVisible: viewGroupsFromOptions.length < VIEW_GROUP_VISIBLE_OPTIONS_MAX, - }); - } - - return viewGroupsFromOptions; + position: emptyViewGroupPosition, + isVisible: emptyViewGroupPosition < VIEW_GROUP_VISIBLE_OPTIONS_MAX, + }, + ]; }; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewAPIUpdate.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewAPIUpdate.ts index 6e82e78fad..e6d80a2042 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewAPIUpdate.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewAPIUpdate.ts @@ -4,14 +4,18 @@ import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetad import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft'; import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; import { type FlatView } from '@/metadata-store/types/FlatView'; +import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup'; import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { computeViewGroupsReplacementForView } from '@/views/utils/computeViewGroupsReplacementForView'; import { CombinedGraphQLErrors } from '@apollo/client/errors'; import { t } from '@lingui/core/macro'; import { useStore } from 'jotai'; import { CrudOperationType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { useMutation } from '@apollo/client/react'; import { + type UpdateViewMutation, type UpdateViewMutationVariables, UpdateViewDocument, } from '~/generated-metadata/graphql'; @@ -19,13 +23,50 @@ import { export const usePerformViewAPIUpdate = () => { const [updateViewMutation] = useMutation(UpdateViewDocument); - const { updateInDraft, applyChanges } = useUpdateMetadataStoreDraft(); + const { updateInDraft, addToDraft, removeFromDraft, applyChanges } = + useUpdateMetadataStoreDraft(); const { handleMetadataError } = useMetadataErrorHandler(); const { enqueueErrorSnackBar } = useSnackBar(); const store = useStore(); + // The server recreates the view groups when mainGroupByFieldMetadataId changes, + // so the store has to be realigned on the groups returned by the mutation + const syncViewGroupsFromMutationResult = useCallback( + ({ + viewId, + updatedViewGroups, + }: { + viewId: string; + updatedViewGroups: UpdateViewMutation['updateView']['viewGroups']; + }) => { + const viewGroupsEntry = store.get( + metadataStoreState.atomFamily('viewGroups'), + ); + + const existingViewGroups = ( + viewGroupsEntry.status === 'draft-pending' + ? viewGroupsEntry.draft + : viewGroupsEntry.current + ) as FlatViewGroup[]; + + const { viewGroupIdsToRemove, viewGroupsToAdd } = + computeViewGroupsReplacementForView({ + viewId, + existingViewGroups, + updatedViewGroups, + }); + + removeFromDraft({ key: 'viewGroups', itemIds: viewGroupIdsToRemove }); + + addToDraft({ key: 'viewGroups', items: viewGroupsToAdd }); + + applyChanges(); + }, + [store, addToDraft, removeFromDraft, applyChanges], + ); + const performViewAPIUpdate = useCallback( async ( variables: UpdateViewMutationVariables, @@ -45,6 +86,21 @@ export const usePerformViewAPIUpdate = () => { variables, }); + const hasUpdatedMainGroupByFieldMetadataId = + variables.input.mainGroupByFieldMetadataId !== undefined; + + const updatedViewGroups = result.data?.updateView.viewGroups; + + if ( + hasUpdatedMainGroupByFieldMetadataId && + isDefined(updatedViewGroups) + ) { + syncViewGroupsFromMutationResult({ + viewId: variables.id, + updatedViewGroups, + }); + } + return { status: 'successful', response: result, @@ -73,6 +129,7 @@ export const usePerformViewAPIUpdate = () => { enqueueErrorSnackBar, updateInDraft, applyChanges, + syncViewGroupsFromMutationResult, store, ], ); diff --git a/packages/twenty-front/src/modules/views/utils/__tests__/computeViewGroupsReplacementForView.test.ts b/packages/twenty-front/src/modules/views/utils/__tests__/computeViewGroupsReplacementForView.test.ts new file mode 100644 index 0000000000..107267e472 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/__tests__/computeViewGroupsReplacementForView.test.ts @@ -0,0 +1,93 @@ +import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup'; +import { computeViewGroupsReplacementForView } from '@/views/utils/computeViewGroupsReplacementForView'; + +const VIEW_ID = '20202020-0000-0000-0000-000000000001'; +const OTHER_VIEW_ID = '20202020-0000-0000-0000-000000000002'; + +const buildExistingViewGroup = ( + viewGroup: Partial, +): FlatViewGroup => ({ + id: '20202020-1111-0000-0000-000000000001', + viewId: VIEW_ID, + fieldValue: 'NEW', + position: 0, + isVisible: true, + ...viewGroup, +}); + +const buildUpdatedViewGroup = ( + viewGroup: Partial, +): FlatViewGroup & { createdAt: string; updatedAt: string } => ({ + ...buildExistingViewGroup(viewGroup), + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}); + +describe('computeViewGroupsReplacementForView', () => { + it('should remove the view groups the server no longer returns for the view', () => { + const { viewGroupIdsToRemove, viewGroupsToAdd } = + computeViewGroupsReplacementForView({ + viewId: VIEW_ID, + existingViewGroups: [ + buildExistingViewGroup({ id: 'stale-group-id', fieldValue: 'NEW' }), + ], + updatedViewGroups: [ + buildUpdatedViewGroup({ id: 'new-group-id', fieldValue: '' }), + ], + }); + + expect(viewGroupIdsToRemove).toEqual(['stale-group-id']); + expect(viewGroupsToAdd).toEqual([ + { + id: 'new-group-id', + viewId: VIEW_ID, + fieldValue: '', + position: 0, + isVisible: true, + }, + ]); + }); + + it('should keep the view groups of the other views untouched', () => { + const { viewGroupIdsToRemove } = computeViewGroupsReplacementForView({ + viewId: VIEW_ID, + existingViewGroups: [ + buildExistingViewGroup({ id: 'stale-group-id' }), + buildExistingViewGroup({ + id: 'other-view-group-id', + viewId: OTHER_VIEW_ID, + }), + ], + updatedViewGroups: [], + }); + + expect(viewGroupIdsToRemove).toEqual(['stale-group-id']); + }); + + it('should remove every view group of the view when the grouping is removed', () => { + const { viewGroupIdsToRemove, viewGroupsToAdd } = + computeViewGroupsReplacementForView({ + viewId: VIEW_ID, + existingViewGroups: [ + buildExistingViewGroup({ id: 'first-group-id' }), + buildExistingViewGroup({ id: 'second-group-id' }), + ], + updatedViewGroups: [], + }); + + expect(viewGroupIdsToRemove).toEqual(['first-group-id', 'second-group-id']); + expect(viewGroupsToAdd).toEqual([]); + }); + + it('should not remove a view group the server still returns', () => { + const { viewGroupIdsToRemove } = computeViewGroupsReplacementForView({ + viewId: VIEW_ID, + existingViewGroups: [buildExistingViewGroup({ id: 'kept-group-id' })], + updatedViewGroups: [ + buildUpdatedViewGroup({ id: 'kept-group-id', position: 3 }), + ], + }); + + expect(viewGroupIdsToRemove).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/views/utils/computeViewGroupsReplacementForView.ts b/packages/twenty-front/src/modules/views/utils/computeViewGroupsReplacementForView.ts new file mode 100644 index 0000000000..54839b79c6 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/computeViewGroupsReplacementForView.ts @@ -0,0 +1,38 @@ +import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup'; +import { type UpdateViewMutation } from '~/generated-metadata/graphql'; + +type UpdatedViewGroup = UpdateViewMutation['updateView']['viewGroups'][number]; + +export const computeViewGroupsReplacementForView = ({ + viewId, + existingViewGroups, + updatedViewGroups, +}: { + viewId: string; + existingViewGroups: FlatViewGroup[]; + updatedViewGroups: UpdatedViewGroup[]; +}): { + viewGroupIdsToRemove: string[]; + viewGroupsToAdd: FlatViewGroup[]; +} => { + const updatedViewGroupIds = new Set( + updatedViewGroups.map((viewGroup) => viewGroup.id), + ); + + const viewGroupIdsToRemove = existingViewGroups + .filter( + (viewGroup) => + viewGroup.viewId === viewId && !updatedViewGroupIds.has(viewGroup.id), + ) + .map((viewGroup) => viewGroup.id); + + const viewGroupsToAdd = updatedViewGroups.map((viewGroup) => ({ + id: viewGroup.id, + viewId: viewGroup.viewId, + fieldValue: viewGroup.fieldValue, + position: viewGroup.position, + isVisible: viewGroup.isVisible, + })); + + return { viewGroupIdsToRemove, viewGroupsToAdd }; +};