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 <charlesBochet@users.noreply.github.com>
This commit is contained in:
Guillim
2025-03-27 16:49:44 +01:00
committed by GitHub
parent 937a393d74
commit 4eefa45164
5 changed files with 275 additions and 37 deletions

View File

@ -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 (
<>
<DropdownMenuHeader
@ -52,38 +88,78 @@ export const ObjectOptionsDropdownLayoutContent = () => {
>
{t`Layout`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => 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 && (
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
onToggleChange={() =>
setAndPersistIsCompactModeActive(
!isCompactModeActive,
currentView,
)
}
toggled={isCompactModeActive}
text={t`Compact view`}
toggleSize="small"
{!!currentView && (
<DropdownMenuItemsContainer>
<MenuItemSelect
LeftIcon={IconTable}
text={t`Table`}
selected={currentView?.type === ViewType.Table}
onClick={async () => {
if (currentView?.type !== ViewType.Table) {
await setAndPersistViewType(ViewType.Table);
}
}}
/>
)}
</DropdownMenuItemsContainer>
<MenuItemSelect
LeftIcon={IconLayoutKanban}
text={t`Kanban`}
disabled={isDefaultView}
contextualText={
isDefaultView
? t`Not available for default view`
: availableFieldsForKanban.length === 0
? t`Create Select...`
: undefined
}
selected={currentView?.type === ViewType.Kanban}
onClick={handleSelectKanbanViewType}
/>
<DropdownMenuSeparator />
<MenuItem
onClick={() => 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 && (
<>
<MenuItem
onClick={() =>
isDefined(recordGroupFieldMetadata)
? onContentChange('recordGroups')
: onContentChange('recordGroupFields')
}
LeftIcon={IconLayoutList}
text={t`Group`}
contextualText={recordGroupFieldMetadata?.label}
hasSubMenu
/>
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
onToggleChange={() =>
setAndPersistIsCompactModeActive(
!isCompactModeActive,
currentView,
)
}
toggled={isCompactModeActive}
text={t`Compact view`}
toggleSize="small"
/>
</>
)}
</DropdownMenuItemsContainer>
)}
</>
);
};

View File

@ -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}
/>
</div>
{!isGroupByEnabled && (

View File

@ -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;

View File

@ -97,7 +97,7 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
/>
}
>
Group by
Group
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{currentView?.key !== 'INDEX' && (

View File

@ -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<GraphQLView> = {};
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,
};
};