Allow moving columns left/right in Kanban view (#11827)

### Demo


https://github.com/user-attachments/assets/9bc81c33-454d-4581-b06e-dbc0ea79a8dd

Closes #11791

---------

Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Abdul Rahman
2025-05-06 15:09:57 +05:30
committed by GitHub
parent e92117d556
commit ef8b015741
4 changed files with 110 additions and 56 deletions

View File

@ -1,21 +1,31 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility';
import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useReorderRecordGroups';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { RecordGroupAction } from '@/object-record/record-group/types/RecordGroupActions'; import { RecordGroupAction } from '@/object-record/record-group/types/RecordGroupActions';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useHasSettingsPermission } from '@/settings/roles/hooks/useHasSettingsPermission'; import { useHasSettingsPermission } from '@/settings/roles/hooks/useHasSettingsPermission';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { t } from '@lingui/core/macro';
import { isUndefined } from '@sniptt/guards';
import { useCallback, useContext } from 'react'; import { useCallback, useContext } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import {
IconArrowLeft,
IconArrowRight,
IconEyeOff,
IconSettings,
} from 'twenty-ui/display';
import { SettingPermissionType } from '~/generated/graphql'; import { SettingPermissionType } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { IconEyeOff, IconSettings } from 'twenty-ui/display';
type UseRecordGroupActionsParams = { type UseRecordGroupActionsParams = {
viewType: ViewType; viewType: ViewType;
@ -50,6 +60,16 @@ export const useRecordGroupActions = ({
navigationMemorizedUrlState, navigationMemorizedUrlState,
); );
const visibleRecordGroupIds = useRecoilComponentFamilyValueV2(
visibleRecordGroupIdsComponentFamilySelector,
viewType,
);
const { reorderRecordGroups } = useReorderRecordGroups({
viewBarId: objectMetadataItem.id,
viewType,
});
const navigateToSelectSettings = useCallback(() => { const navigateToSelectSettings = useCallback(() => {
setNavigationMemorizedUrl(location.pathname + location.search); setNavigationMemorizedUrl(location.pathname + location.search);
@ -73,33 +93,60 @@ export const useRecordGroupActions = ({
const hasAccessToDataModelSettings = useHasSettingsPermission( const hasAccessToDataModelSettings = useHasSettingsPermission(
SettingPermissionType.DATA_MODEL, SettingPermissionType.DATA_MODEL,
); );
const currentIndex = visibleRecordGroupIds.findIndex(
(id) => id === recordGroupDefinition.id,
);
const isCurrentRecordGroupNotFound = currentIndex === -1;
const recordGroupActions: RecordGroupAction[] = []; const recordGroupActions: RecordGroupAction[] = [
{
if (hasAccessToDataModelSettings) {
recordGroupActions.push({
id: 'edit', id: 'edit',
label: 'Edit', label: t`Edit`,
icon: IconSettings, icon: IconSettings,
position: 0, position: 0,
callback: () => { condition: hasAccessToDataModelSettings,
navigateToSelectSettings(); callback: navigateToSelectSettings,
},
});
}
recordGroupActions.push({
id: 'hide',
label: 'Hide',
icon: IconEyeOff,
position: 1,
callback: () => {
handleRecordGroupVisibilityChange({
...recordGroupDefinition,
isVisible: false,
});
}, },
}); {
id: 'moveRight',
label: t`Move right`,
icon: IconArrowRight,
condition:
!isCurrentRecordGroupNotFound &&
currentIndex < visibleRecordGroupIds.length - 1,
position: 1,
callback: () =>
reorderRecordGroups({
fromIndex: currentIndex,
toIndex: currentIndex + 1,
}),
},
{
id: 'moveLeft',
label: t`Move left`,
icon: IconArrowLeft,
condition: !isCurrentRecordGroupNotFound && currentIndex > 0,
position: 2,
callback: () =>
reorderRecordGroups({
fromIndex: currentIndex,
toIndex: currentIndex - 1,
}),
},
{
id: 'hide',
label: t`Hide`,
icon: IconEyeOff,
position: 3,
callback: () =>
handleRecordGroupVisibilityChange({
...recordGroupDefinition,
isVisible: false,
}),
},
];
return recordGroupActions; return recordGroupActions.filter(
({ condition }) => isUndefined(condition) || condition !== false,
);
}; };

View File

@ -1,4 +1,4 @@
import { useRecordGroupReorder } from '@/object-record/record-group/hooks/useRecordGroupReorder'; import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useReorderRecordGroups';
import { isRecordGroupReorderConfirmationModalVisibleState } from '@/object-record/record-group/states/isRecordGroupReorderConfirmationModalVisibleState'; import { isRecordGroupReorderConfirmationModalVisibleState } from '@/object-record/record-group/states/isRecordGroupReorderConfirmationModalVisibleState';
import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort';
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
@ -30,15 +30,24 @@ export const useRecordGroupReorderConfirmationModal = ({
isRecordGroupReorderConfirmationModalVisibleState, isRecordGroupReorderConfirmationModalVisibleState,
); );
const [pendingDragEndReorder, setPendingDragEndReorder] = const [pendingDragEndHandlerParams, setPendingDragEndHandlerParams] =
useState<Parameters<OnDragEndResponder> | null>(null); useState<Parameters<OnDragEndResponder> | null>(null);
const { handleOrderChange: handleRecordGroupOrderChange } = const { reorderRecordGroups } = useReorderRecordGroups({
useRecordGroupReorder({ viewBarId: recordIndexId,
viewBarId: recordIndexId, viewType,
viewType, });
});
const handleDragEnd: OnDragEndResponder = (result) => {
if (!result.destination) {
return;
}
reorderRecordGroups({
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
};
const isDragableSortRecordGroup = useRecoilComponentValueV2( const isDragableSortRecordGroup = useRecoilComponentValueV2(
recordIndexRecordGroupIsDraggableSortComponentSelector, recordIndexRecordGroupIsDraggableSortComponentSelector,
); );
@ -47,32 +56,29 @@ export const useRecordGroupReorderConfirmationModal = ({
recordIndexRecordGroupSortComponentState, recordIndexRecordGroupSortComponentState,
); );
const handleRecordGroupOrderChangeWithModal: OnDragEndResponder = ( const handleDragEndWithModal: OnDragEndResponder = (result, provided) => {
result,
provided,
) => {
if (!isDragableSortRecordGroup) { if (!isDragableSortRecordGroup) {
setIsRecordGroupReorderConfirmationModalVisible(true); setIsRecordGroupReorderConfirmationModalVisible(true);
setActiveDropdownFocusIdAndMemorizePrevious(null); setActiveDropdownFocusIdAndMemorizePrevious(null);
setPendingDragEndReorder([result, provided]); setPendingDragEndHandlerParams([result, provided]);
} else { } else {
handleRecordGroupOrderChange(result, provided); handleDragEnd(result, provided);
} }
}; };
const handleConfirmClick = () => { const handleConfirmClick = () => {
if (!pendingDragEndReorder) { if (!pendingDragEndHandlerParams) {
throw new Error('pendingDragEndReorder is not set'); throw new Error('pendingDragEndReorder is not set');
} }
setRecordGroupSort(RecordGroupSort.Manual); setRecordGroupSort(RecordGroupSort.Manual);
setPendingDragEndReorder(null); setPendingDragEndHandlerParams(null);
handleRecordGroupOrderChange(...pendingDragEndReorder); handleDragEnd(...pendingDragEndHandlerParams);
goBackToPreviousDropdownFocusId(); goBackToPreviousDropdownFocusId();
}; };
return { return {
handleRecordGroupOrderChangeWithModal, handleRecordGroupOrderChangeWithModal: handleDragEndWithModal,
handleRecordGroupReorderConfirmClick: handleConfirmClick, handleRecordGroupReorderConfirmClick: handleConfirmClick,
}; };
}; };

View File

@ -1,5 +1,3 @@
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups';
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
@ -11,19 +9,24 @@ import { useSaveCurrentViewGroups } from '@/views/hooks/useSaveCurrentViewGroups
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups'; import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from 'twenty-shared/utils';
type UseRecordGroupHandlersParams = { type UseReorderRecordGroupsParams = {
viewBarId: string; viewBarId: string;
viewType: ViewType; viewType: ViewType;
}; };
export const useRecordGroupReorder = ({ type ReorderRecordGroupsParams = {
fromIndex: number;
toIndex: number;
};
export const useReorderRecordGroups = ({
viewBarId, viewBarId,
viewType, viewType,
}: UseRecordGroupHandlersParams) => { }: UseReorderRecordGroupsParams) => {
const { setRecordGroups } = useSetRecordGroups(); const { setRecordGroups } = useSetRecordGroups();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
@ -33,13 +36,9 @@ export const useRecordGroupReorder = ({
const { saveViewGroups } = useSaveCurrentViewGroups(); const { saveViewGroups } = useSaveCurrentViewGroups();
const handleOrderChange: OnDragEndResponder = useRecoilCallback( const reorderRecordGroups = useRecoilCallback(
({ snapshot }) => ({ snapshot }) =>
(result) => { ({ fromIndex, toIndex }: ReorderRecordGroupsParams) => {
if (!result.destination) {
return;
}
const visibleRecordGroupIds = getSnapshotValue( const visibleRecordGroupIds = getSnapshotValue(
snapshot, snapshot,
visibleRecordGroupIdsFamilySelector(viewType), visibleRecordGroupIdsFamilySelector(viewType),
@ -48,8 +47,8 @@ export const useRecordGroupReorder = ({
const reorderedVisibleRecordGroupIds = moveArrayItem( const reorderedVisibleRecordGroupIds = moveArrayItem(
visibleRecordGroupIds, visibleRecordGroupIds,
{ {
fromIndex: result.source.index - 1, fromIndex,
toIndex: result.destination.index - 1, toIndex,
}, },
); );
@ -96,6 +95,6 @@ export const useRecordGroupReorder = ({
); );
return { return {
handleOrderChange, reorderRecordGroups,
}; };
}; };

View File

@ -1,8 +1,10 @@
import { IconComponent } from 'twenty-ui/display'; import { IconComponent } from 'twenty-ui/display';
export type RecordGroupAction = { export type RecordGroupAction = {
id: string; id: string;
label: string; label: string;
icon: IconComponent; icon: IconComponent;
position: number; position: number;
callback: () => void; callback: () => void;
condition?: boolean;
}; };