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:
@ -1,23 +1,34 @@
|
|||||||
import {
|
import {
|
||||||
IconBaselineDensitySmall,
|
IconBaselineDensitySmall,
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
|
IconLayoutKanban,
|
||||||
|
IconLayoutList,
|
||||||
IconLayoutNavbar,
|
IconLayoutNavbar,
|
||||||
IconLayoutSidebarRight,
|
IconLayoutSidebarRight,
|
||||||
|
IconTable,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
MenuItemSelect,
|
||||||
MenuItemToggle,
|
MenuItemToggle,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
|
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 { 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 { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
|
||||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
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 { 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 { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||||
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
|
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
|
||||||
import { ViewType } from '@/views/types/ViewType';
|
import { ViewType } from '@/views/types/ViewType';
|
||||||
|
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export const ObjectOptionsDropdownLayoutContent = () => {
|
export const ObjectOptionsDropdownLayoutContent = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@ -26,9 +37,9 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
|||||||
const {
|
const {
|
||||||
recordIndexId,
|
recordIndexId,
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
viewType,
|
|
||||||
resetContent,
|
resetContent,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
|
dropdownId,
|
||||||
} = useOptionsDropdown();
|
} = useOptionsDropdown();
|
||||||
|
|
||||||
const { isCompactModeActive, setAndPersistIsCompactModeActive } =
|
const { isCompactModeActive, setAndPersistIsCompactModeActive } =
|
||||||
@ -40,6 +51,31 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
|||||||
|
|
||||||
const recordIndexOpenRecordIn = useRecoilValue(recordIndexOpenRecordInState);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuHeader
|
<DropdownMenuHeader
|
||||||
@ -52,38 +88,78 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
|||||||
>
|
>
|
||||||
{t`Layout`}
|
{t`Layout`}
|
||||||
</DropdownMenuHeader>
|
</DropdownMenuHeader>
|
||||||
<DropdownMenuItemsContainer>
|
{!!currentView && (
|
||||||
<MenuItem
|
<DropdownMenuItemsContainer>
|
||||||
onClick={() => onContentChange('layoutOpenIn')}
|
<MenuItemSelect
|
||||||
LeftIcon={
|
LeftIcon={IconTable}
|
||||||
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL
|
text={t`Table`}
|
||||||
? IconLayoutSidebarRight
|
selected={currentView?.type === ViewType.Table}
|
||||||
: IconLayoutNavbar
|
onClick={async () => {
|
||||||
}
|
if (currentView?.type !== ViewType.Table) {
|
||||||
text={t`Open in`}
|
await setAndPersistViewType(ViewType.Table);
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
<MenuItemSelect
|
||||||
</DropdownMenuItemsContainer>
|
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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -74,6 +74,8 @@ export const ObjectOptionsDropdownMenuContent = () => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
|
const isDefaultView = currentView?.key === 'INDEX';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{currentView && (
|
{currentView && (
|
||||||
@ -110,14 +112,14 @@ export const ObjectOptionsDropdownMenuContent = () => {
|
|||||||
: onContentChange('recordGroupFields')
|
: onContentChange('recordGroupFields')
|
||||||
}
|
}
|
||||||
LeftIcon={IconLayoutList}
|
LeftIcon={IconLayoutList}
|
||||||
text={t`Group by`}
|
text={t`Group`}
|
||||||
contextualText={
|
contextualText={
|
||||||
!isGroupByEnabled
|
isDefaultView
|
||||||
? t`Not available on Default View`
|
? t`Not available on Default View`
|
||||||
: recordGroupFieldMetadata?.label
|
: recordGroupFieldMetadata?.label
|
||||||
}
|
}
|
||||||
hasSubMenu
|
hasSubMenu
|
||||||
disabled={!isGroupByEnabled}
|
disabled={isDefaultView}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isGroupByEnabled && (
|
{!isGroupByEnabled && (
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const StyledMenuTitleContainer = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledMenuIconContainer = styled.div`
|
const StyledMenuIconContainer = styled.div`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: ${({ theme }) => theme.spacing(6)};
|
height: ${({ theme }) => theme.spacing(6)};
|
||||||
@ -44,6 +45,7 @@ const StyledMenuIconContainer = styled.div`
|
|||||||
width: ${({ theme }) => theme.spacing(6)};
|
width: ${({ theme }) => theme.spacing(6)};
|
||||||
`;
|
`;
|
||||||
const StyledMainText = styled.div`
|
const StyledMainText = styled.div`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@ -97,7 +97,7 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Group by
|
Group
|
||||||
</DropdownMenuHeader>
|
</DropdownMenuHeader>
|
||||||
<DropdownMenuItemsContainer>
|
<DropdownMenuItemsContainer>
|
||||||
{currentView?.key !== 'INDEX' && (
|
{currentView?.key !== 'INDEX' && (
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user