From 4eefa45164a6fd090bdfb259811eb6d97958f465 Mon Sep 17 00:00:00 2001 From: Guillim Date: Thu, 27 Mar 2025 16:49:44 +0100 Subject: [PATCH] Option-menu-switch-table-kanban (#11167) New options menu feature: table/kanban switching This feature is the last one of the Optino Menu v2 update. It is designed to enable the possibility to switch from table to kanban and vice-versa. Only the default tab is not editable for consitency in the UX of the application as designed by our team --------- Co-authored-by: Charles Bochet --- .../ObjectOptionsDropdownLayoutContent.tsx | 142 ++++++++++++---- .../ObjectOptionsDropdownMenuContent.tsx | 8 +- .../ObjectOptionsDropdownMenuViewName.tsx | 2 + ...jectOptionsDropdownRecordGroupsContent.tsx | 2 +- .../hooks/useObjectOptionsForLayout.ts | 158 ++++++++++++++++++ 5 files changed, 275 insertions(+), 37 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForLayout.ts diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx index af1db6e8f..b67fcc5f0 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx @@ -1,23 +1,34 @@ import { IconBaselineDensitySmall, IconChevronLeft, + IconLayoutKanban, + IconLayoutList, IconLayoutNavbar, IconLayoutSidebarRight, + IconTable, MenuItem, + MenuItemSelect, MenuItemToggle, } from 'twenty-ui'; import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; +import { useSetViewTypeFromLayoutOptionsMenu } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForLayout'; import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown'; +import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; +import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType'; import { ViewType } from '@/views/types/ViewType'; +import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban'; import { useLingui } from '@lingui/react/macro'; import { useRecoilValue } from 'recoil'; -import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; +import { isDefined } from 'twenty-shared/utils'; export const ObjectOptionsDropdownLayoutContent = () => { const { t } = useLingui(); @@ -26,9 +37,9 @@ export const ObjectOptionsDropdownLayoutContent = () => { const { recordIndexId, objectMetadataItem, - viewType, resetContent, onContentChange, + dropdownId, } = useOptionsDropdown(); const { isCompactModeActive, setAndPersistIsCompactModeActive } = @@ -40,6 +51,31 @@ export const ObjectOptionsDropdownLayoutContent = () => { const recordIndexOpenRecordIn = useRecoilValue(recordIndexOpenRecordInState); + const recordGroupFieldMetadata = useRecoilComponentValueV2( + recordGroupFieldMetadataComponentState, + ); + + const { setAndPersistViewType } = useSetViewTypeFromLayoutOptionsMenu(); + const { availableFieldsForKanban, navigateToSelectSettings } = + useGetAvailableFieldsForKanban(); + + const { closeDropdown } = useDropdown(dropdownId); + + const handleSelectKanbanViewType = async () => { + if (isDefaultView) { + return; + } + if (availableFieldsForKanban.length === 0) { + navigateToSelectSettings(); + closeDropdown(); + } + if (currentView?.type !== ViewType.Kanban) { + await setAndPersistViewType(ViewType.Kanban); + } + }; + + const isDefaultView = currentView?.key === 'INDEX'; + return ( <> { > {t`Layout`} - - onContentChange('layoutOpenIn')} - LeftIcon={ - recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL - ? IconLayoutSidebarRight - : IconLayoutNavbar - } - text={t`Open in`} - contextualText={ - recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL - ? t`Side Panel` - : t`Record Page` - } - hasSubMenu - /> - - {viewType === ViewType.Kanban && ( - - setAndPersistIsCompactModeActive( - !isCompactModeActive, - currentView, - ) - } - toggled={isCompactModeActive} - text={t`Compact view`} - toggleSize="small" + {!!currentView && ( + + { + if (currentView?.type !== ViewType.Table) { + await setAndPersistViewType(ViewType.Table); + } + }} /> - )} - + + + onContentChange('layoutOpenIn')} + LeftIcon={ + recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL + ? IconLayoutSidebarRight + : IconLayoutNavbar + } + text={t`Open in`} + contextualText={ + recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL + ? t`Side Panel` + : t`Record Page` + } + hasSubMenu + /> + {currentView?.type === ViewType.Kanban && ( + <> + + isDefined(recordGroupFieldMetadata) + ? onContentChange('recordGroups') + : onContentChange('recordGroupFields') + } + LeftIcon={IconLayoutList} + text={t`Group`} + contextualText={recordGroupFieldMetadata?.label} + hasSubMenu + /> + + + setAndPersistIsCompactModeActive( + !isCompactModeActive, + currentView, + ) + } + toggled={isCompactModeActive} + text={t`Compact view`} + toggleSize="small" + /> + + )} + + )} ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx index 8462b4155..cf68237fd 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuContent.tsx @@ -74,6 +74,8 @@ export const ObjectOptionsDropdownMenuContent = () => { const theme = useTheme(); const { enqueueSnackBar } = useSnackBar(); + const isDefaultView = currentView?.key === 'INDEX'; + return ( <> {currentView && ( @@ -110,14 +112,14 @@ export const ObjectOptionsDropdownMenuContent = () => { : onContentChange('recordGroupFields') } LeftIcon={IconLayoutList} - text={t`Group by`} + text={t`Group`} contextualText={ - !isGroupByEnabled + isDefaultView ? t`Not available on Default View` : recordGroupFieldMetadata?.label } hasSubMenu - disabled={!isGroupByEnabled} + disabled={isDefaultView} /> {!isGroupByEnabled && ( diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuViewName.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuViewName.tsx index 4ba918f4a..c8047eca7 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuViewName.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownMenuViewName.tsx @@ -37,6 +37,7 @@ const StyledMenuTitleContainer = styled.div` `; const StyledMenuIconContainer = styled.div` + color: ${({ theme }) => theme.font.color.primary}; align-items: center; display: flex; height: ${({ theme }) => theme.spacing(6)}; @@ -44,6 +45,7 @@ const StyledMenuIconContainer = styled.div` width: ${({ theme }) => theme.spacing(6)}; `; const StyledMainText = styled.div` + color: ${({ theme }) => theme.font.color.primary}; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; 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 07a37a4e5..8c42139db 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 @@ -97,7 +97,7 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { /> } > - Group by + Group {currentView?.key !== 'INDEX' && ( diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForLayout.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForLayout.ts new file mode 100644 index 000000000..91e2cad04 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useObjectOptionsForLayout.ts @@ -0,0 +1,158 @@ +import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; + +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; +import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; +import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords'; +import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView'; +import { GraphQLView } from '@/views/types/GraphQLView'; +import { ViewGroup } from '@/views/types/ViewGroup'; +import { ViewType } from '@/views/types/ViewType'; +import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban'; +import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerKanbanFieldMetadataIdComponentState'; +import { useCallback } from 'react'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +export const useSetViewTypeFromLayoutOptionsMenu = () => { + const { updateCurrentView } = useUpdateCurrentView(); + const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState); + const { availableFieldsForKanban } = useGetAvailableFieldsForKanban(); + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const setViewPickerKanbanFieldMetadataId = useSetRecoilComponentStateV2( + viewPickerKanbanFieldMetadataIdComponentState, + ); + + const { loadRecordIndexStates } = useLoadRecordIndexStates(); + + const { createViewGroupRecords } = usePersistViewGroupRecords(); + + const createViewGroupAssociatedWithKanbanField = useCallback( + async (randomFieldForKanban: string, currentViewId: string) => { + const viewGroupsToCreate = + objectMetadataItem.fields + ?.find((field) => field.id === randomFieldForKanban) + ?.options?.map( + (option, index) => + ({ + id: v4(), + __typename: 'ViewGroup', + fieldMetadataId: randomFieldForKanban, + fieldValue: option.value, + isVisible: true, + position: index, + }) satisfies ViewGroup, + ) ?? []; + + viewGroupsToCreate.push({ + __typename: 'ViewGroup', + id: v4(), + fieldValue: '', + position: viewGroupsToCreate.length, + isVisible: true, + fieldMetadataId: randomFieldForKanban, + } satisfies ViewGroup); + + await createViewGroupRecords({ + viewGroupsToCreate, + viewId: currentViewId, + }); + + return viewGroupsToCreate; + }, + [objectMetadataItem, createViewGroupRecords], + ); + + const setAndPersistViewType = useRecoilCallback( + ({ snapshot }) => + async (viewType: ViewType) => { + const currentViewId = snapshot + .getLoadable( + contextStoreCurrentViewIdComponentState.atomFamily({ + instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID, + }), + ) + .getValue(); + + if (!isDefined(currentViewId)) { + throw new Error('No view id found'); + } + const currentView = snapshot + .getLoadable( + prefetchViewFromViewIdFamilySelector({ viewId: currentViewId }), + ) + .getValue(); + if (!isDefined(currentView)) { + throw new Error('No current view found'); + } + + const updateCurrentViewParams: Partial = {}; + updateCurrentViewParams.type = viewType; + + switch (viewType) { + case ViewType.Kanban: { + if (availableFieldsForKanban.length === 0) { + throw new Error('No fields for kanban - should not happen'); + } + const previouslySelectedKanbanField = availableFieldsForKanban.find( + (fieldsForKanban) => + fieldsForKanban.id === currentView.kanbanFieldMetadataId, + ); + + const kanbanField = isDefined(previouslySelectedKanbanField) + ? previouslySelectedKanbanField + : availableFieldsForKanban[0]; + + if (!isDefined(previouslySelectedKanbanField)) { + updateCurrentViewParams.kanbanFieldMetadataId = kanbanField.id; + } + + const hasViewGroups = currentView.viewGroups.some( + (viewGroup: ViewGroup) => + viewGroup.fieldMetadataId === kanbanField.id, + ); + + if (!hasViewGroups) { + const viewGroups = await createViewGroupAssociatedWithKanbanField( + kanbanField.id, + currentView.id, + ); + loadRecordIndexStates( + { ...currentView, viewGroups }, + objectMetadataItem, + ); + setViewPickerKanbanFieldMetadataId(kanbanField.id); + } + setRecordIndexViewType(viewType); + await updateCurrentView(updateCurrentViewParams); + break; + } + case ViewType.Table: + setRecordIndexViewType(viewType); + await updateCurrentView(updateCurrentViewParams); + break; + default: { + return assertUnreachable(viewType); + } + } + }, + [ + availableFieldsForKanban, + objectMetadataItem, + updateCurrentView, + setRecordIndexViewType, + createViewGroupAssociatedWithKanbanField, + setViewPickerKanbanFieldMetadataId, + loadRecordIndexStates, + ], + ); + + return { + setAndPersistViewType, + }; +};