From 369b1b19af9d2d0cf87fa468f37a79bf81ec39a9 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:45:31 +0530 Subject: [PATCH] Tabs sidepanel settings on dashboards (#15454) question -- should we have confirmation modal on delete -- if yes -- should it appear on save -- or on delete? figma - https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=79469-817497&t=5gFTRiI5f8nPyzc6-0 Note - Figma has a new side panel header, which is unavailable for now, so I used the existing one In a follow-up, we could even have icons for tabs -- would be cool -- and an icon picker for the icons? followups which could be addressed in separate PRs - - ~~tabs being edited state (select state?) -- this would have blue border around tab~~ done - add icons on tabs -- need to update the entity to add icon -- and set a default (dashboard) -- at least - icon picker in the sidepanel header closes - https://discord.com/channels/1130383047699738754/1430204556884836532 video QA https://github.com/user-attachments/assets/a4ad4b93-911d-46ce-9b68-347fd48fe933 --- .../constants/CommandMenuPagesConfig.tsx | 5 + ...ommandMenuCloseAnimationCompleteCleanup.ts | 10 +- .../CommandMenuPageLayoutTabSettings.tsx | 124 ++++++++++++++++++ .../settings/TabSettingsSelectableItemIds.ts | 5 + .../types/PageLayoutCommandMenuPage.ts | 3 +- .../page-layout/utils/getPageLayoutIcon.ts | 2 + .../utils/getPageLayoutPageTitle.ts | 2 + .../command-menu/types/CommandMenuPages.ts | 1 + .../components/PageLayoutRendererContent.tsx | 23 +++- .../components/PageLayoutTabList.tsx | 66 +++++++++- ...youtTabListReorderableOverflowDropdown.tsx | 40 +++++- .../PageLayoutTabListReorderableTab.tsx | 21 ++- .../PageLayoutTabListVisibleTabs.tsx | 4 - .../PageLayoutTabMenuItemSelectAvatar.tsx | 97 ++++++++++++++ .../hooks/useCreatePageLayoutTab.ts | 10 +- .../hooks/useDeletePageLayoutTab.ts | 68 ++++++++++ .../page-layout/hooks/useMovePageLayoutTab.ts | 79 +++++++++++ .../hooks/useUpdatePageLayoutTab.ts | 33 +++++ ...ayoutTabSettingsOpenTabIdComponentState.ts | 10 ++ .../page-layout/utils/removeTabLayouts.ts | 13 ++ .../page-layout/utils/sortTabsByPosition.ts | 5 + .../internals/components/TabContent.tsx | 4 +- 22 files changed, 602 insertions(+), 23 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/TabSettingsSelectableItemIds.ts create mode 100644 packages/twenty-front/src/modules/page-layout/components/PageLayoutTabMenuItemSelectAvatar.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutTab.ts create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useUpdatePageLayoutTab.ts create mode 100644 packages/twenty-front/src/modules/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/removeTabLayouts.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/sortTabsByPosition.ts diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index 73878d48d1a..edafe288c41 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -7,6 +7,7 @@ import { CommandMenuPageLayoutGraphFilter } from '@/command-menu/pages/page-layo import { CommandMenuPageLayoutGraphTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect'; import { CommandMenuPageLayoutIframeConfig } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeConfig'; import { CommandMenuPageLayoutWidgetTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect'; +import { CommandMenuPageLayoutTabSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings'; import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage'; import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage'; import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage'; @@ -57,4 +58,8 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map< CommandMenuPages.PageLayoutIframeConfig, , ], + [ + CommandMenuPages.PageLayoutTabSettings, + , + ], ]); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts index ccfcbd2b7ae..2c4c2ddfb7d 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup.ts @@ -16,6 +16,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { emitSidePanelCloseEvent } from '@/ui/layout/right-drawer/utils/emitSidePanelCloseEvent'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; @@ -50,7 +51,8 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => { const isPageLayoutEditingPage = currentPage === CommandMenuPages.PageLayoutWidgetTypeSelect || currentPage === CommandMenuPages.PageLayoutGraphTypeSelect || - currentPage === CommandMenuPages.PageLayoutIframeConfig; + currentPage === CommandMenuPages.PageLayoutIframeConfig || + currentPage === CommandMenuPages.PageLayoutTabSettings; if (isPageLayoutEditingPage) { const targetedRecordsRule = snapshot @@ -77,6 +79,12 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => { }), null, ); + set( + pageLayoutTabSettingsOpenTabIdComponentState.atomFamily({ + instanceId: record.pageLayoutId, + }), + null, + ); } } } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings.tsx new file mode 100644 index 00000000000..59d028b2fe5 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings.tsx @@ -0,0 +1,124 @@ +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { TAB_SETTINGS_SELECTABLE_ITEM_IDS } from '@/command-menu/pages/page-layout/constants/settings/TabSettingsSelectableItemIds'; +import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { useDeletePageLayoutTab } from '@/page-layout/hooks/useDeletePageLayoutTab'; +import { useMovePageLayoutTab } from '@/page-layout/hooks/useMovePageLayoutTab'; +import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; +import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useTheme } from '@emotion/react'; +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconAppWindow, + IconChevronLeft, + IconChevronRight, + IconTrash, +} from 'twenty-ui/display'; + +export const CommandMenuPageLayoutTabSettings = () => { + const theme = useTheme(); + const { closeCommandMenu } = useCommandMenu(); + const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + + const draft = useRecoilComponentValue( + pageLayoutDraftComponentState, + pageLayoutId, + ); + const [openTabId, setOpenTabId] = useRecoilComponentState( + pageLayoutTabSettingsOpenTabIdComponentState, + pageLayoutId, + ); + + const { moveLeft, moveRight } = useMovePageLayoutTab(pageLayoutId); + const { deleteTab } = useDeletePageLayoutTab(pageLayoutId); + const { updatePageLayoutTab } = useUpdatePageLayoutTab(pageLayoutId); + + if (!isDefined(openTabId)) { + return null; + } + + const tabsSorted = sortTabsByPosition(draft.tabs); + const currentIndex = tabsSorted.findIndex((t) => t.id === openTabId); + if (currentIndex < 0) return null; + const tab = tabsSorted[currentIndex]; + const disableMoveLeft = currentIndex <= 0; + const disableMoveRight = currentIndex >= tabsSorted.length - 1; + const disableDelete = tabsSorted.length <= 1; + + const handleDelete = () => { + if (!disableDelete) { + deleteTab(tab.id); + setOpenTabId(null); + closeCommandMenu(); + } + }; + + return ( + <> + { + if (isDefined(newTitle) && isNonEmptyString(newTitle)) { + updatePageLayoutTab(tab.id, { title: newTitle }); + } + }} + /> + + + moveLeft(tab.id)} + > + moveLeft(tab.id)} + disabled={disableMoveLeft} + /> + + moveRight(tab.id)} + > + moveRight(tab.id)} + disabled={disableMoveRight} + /> + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/TabSettingsSelectableItemIds.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/TabSettingsSelectableItemIds.ts new file mode 100644 index 00000000000..0479e4cbe9c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/TabSettingsSelectableItemIds.ts @@ -0,0 +1,5 @@ +export const TAB_SETTINGS_SELECTABLE_ITEM_IDS = { + MOVE_LEFT: 'tab-move-left', + MOVE_RIGHT: 'tab-move-right', + DELETE: 'tab-delete', +} as const; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PageLayoutCommandMenuPage.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PageLayoutCommandMenuPage.ts index 7fc0342dc24..6847030bd4e 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PageLayoutCommandMenuPage.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PageLayoutCommandMenuPage.ts @@ -4,4 +4,5 @@ export type PageLayoutCommandMenuPage = | CommandMenuPages.PageLayoutWidgetTypeSelect | CommandMenuPages.PageLayoutGraphTypeSelect | CommandMenuPages.PageLayoutIframeConfig - | CommandMenuPages.PageLayoutGraphFilter; + | CommandMenuPages.PageLayoutGraphFilter + | CommandMenuPages.PageLayoutTabSettings; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutIcon.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutIcon.ts index c0516e9e4ea..212e48a0eb7 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutIcon.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutIcon.ts @@ -18,6 +18,8 @@ export const getPageLayoutIcon = (page: PageLayoutCommandMenuPage) => { return IconFrame; case CommandMenuPages.PageLayoutGraphFilter: return IconFilter; + case CommandMenuPages.PageLayoutTabSettings: + return IconAppWindow; default: assertUnreachable(page); } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutPageTitle.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutPageTitle.ts index 3e18df4e3e4..75cb813565e 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutPageTitle.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getPageLayoutPageTitle.ts @@ -13,6 +13,8 @@ export const getPageLayoutPageTitle = (page: PageLayoutCommandMenuPage) => { return t`Configure iFrame`; case CommandMenuPages.PageLayoutGraphFilter: return t`Configure filters`; + case CommandMenuPages.PageLayoutTabSettings: + return t`Tab Settings`; default: assertUnreachable(page); } diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 24a4664a9b9..60b1b526351 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -19,4 +19,5 @@ export enum CommandMenuPages { PageLayoutGraphTypeSelect = 'page-layout-graph-type-select', PageLayoutGraphFilter = 'page-layout-graph-filter', PageLayoutIframeConfig = 'page-layout-iframe-config', + PageLayoutTabSettings = 'page-layout-tab-settings', } diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx index cc9ea319cb1..cb52dba85d3 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx @@ -7,14 +7,19 @@ import { useCreatePageLayoutTab } from '@/page-layout/hooks/useCreatePageLayoutT import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; import { useReorderPageLayoutTabs } from '@/page-layout/hooks/useReorderPageLayoutTabs'; import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode'; +import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import styled from '@emotion/styled'; import { isDefined } from 'twenty-shared/utils'; @@ -57,8 +62,20 @@ export const PageLayoutRendererContent = () => { const { createPageLayoutTab } = useCreatePageLayoutTab(currentPageLayout?.id); const { reorderTabs } = useReorderPageLayoutTabs(currentPageLayout?.id ?? ''); + const setTabSettingsOpenTabId = useSetRecoilComponentState( + pageLayoutTabSettingsOpenTabIdComponentState, + ); + const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu(); - const handleAddTab = isPageLayoutInEditMode ? createPageLayoutTab : undefined; + const handleAddTab = isPageLayoutInEditMode + ? () => { + const newTabId = createPageLayoutTab('Untitled'); + setTabSettingsOpenTabId(newTabId); + navigatePageLayoutCommandMenu({ + commandMenuPage: CommandMenuPages.PageLayoutTabSettings, + }); + } + : undefined; const isMobile = useIsMobile(); @@ -76,9 +93,7 @@ export const PageLayoutRendererContent = () => { currentPageLayout.id, ); - const sortedTabs = [...tabsToRenderInTabList].sort( - (a, b) => a.position - b.position, - ); + const sortedTabs = sortTabsByPosition(tabsToRenderInTabList); return ( diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx index 183ecb6c10a..e7960253080 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx @@ -27,11 +27,18 @@ import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useC import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListReorderableOverflowDropdown } from '@/page-layout/components/PageLayoutTabListReorderableOverflowDropdown'; import { PageLayoutTabListStaticOverflowDropdown } from '@/page-layout/components/PageLayoutTabListStaticOverflowDropdown'; import { PageLayoutTabListVisibleTabs } from '@/page-layout/components/PageLayoutTabListVisibleTabs'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { isDefined } from 'twenty-shared/utils'; const StyledContainer = styled.div` @@ -180,6 +187,57 @@ export const PageLayoutTabList = ({ [onReorder, setIsTabDragging, toggleClickOutside, openDropdown, dropdownId], ); + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + ); + const setTabSettingsOpenTabId = useSetRecoilComponentState( + pageLayoutTabSettingsOpenTabIdComponentState, + pageLayoutId, + ); + const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu(); + + const openTabSettings = useCallback( + (tabId: string) => { + setTabSettingsOpenTabId(tabId); + navigatePageLayoutCommandMenu({ + commandMenuPage: CommandMenuPages.PageLayoutTabSettings, + }); + }, + [setTabSettingsOpenTabId, navigatePageLayoutCommandMenu], + ); + + const handleSelectTab = useCallback( + (tabId: string) => { + if (isPageLayoutInEditMode && activeTabId === tabId) { + openTabSettings(tabId); + return; + } + selectTab(tabId); + }, + [isPageLayoutInEditMode, activeTabId, openTabSettings, selectTab], + ); + + const handleSelectTabFromDropdown = useCallback( + (tabId: string) => { + if (isPageLayoutInEditMode && activeTabId === tabId) { + openTabSettings(tabId); + closeOverflowDropdown(); + return; + } + selectTabFromDropdown(tabId); + }, + [ + isPageLayoutInEditMode, + activeTabId, + openTabSettings, + closeOverflowDropdown, + selectTabFromDropdown, + ], + ); + if (visibleTabs.length === 0) { return null; } @@ -232,7 +290,7 @@ export const PageLayoutTabList = ({ behaveAsLinks={behaveAsLinks} loading={loading} onChangeTab={onChangeTab} - onSelectTab={selectTab} + onSelectTab={handleSelectTab} canReorder={canReorderTabs} /> @@ -244,7 +302,7 @@ export const PageLayoutTabList = ({ isActiveTabHidden={isActiveTabHidden} activeTabId={activeTabId || ''} loading={loading} - onSelect={selectTabFromDropdown} + onSelect={handleSelectTabFromDropdown} visibleTabCount={visibleTabCount} onClose={closeOverflowDropdown} /> @@ -271,7 +329,7 @@ export const PageLayoutTabList = ({ behaveAsLinks={behaveAsLinks} loading={loading} onChangeTab={onChangeTab} - onSelectTab={selectTab} + onSelectTab={handleSelectTab} canReorder={canReorderTabs} /> {shouldRenderStaticDropdown && ( @@ -282,7 +340,7 @@ export const PageLayoutTabList = ({ isActiveTabHidden={isActiveTabHidden} activeTabId={activeTabId || ''} loading={loading} - onSelect={selectTabFromDropdown} + onSelect={handleSelectTabFromDropdown} onClose={closeOverflowDropdown} /> )} diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx index c21b913d37f..f68ef93a5f4 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableOverflowDropdown.tsx @@ -8,21 +8,26 @@ import { Droppable, } from '@hello-pangea/dnd'; +import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListDroppableMoreButton } from '@/page-layout/components/PageLayoutTabListDroppableMoreButton'; +import { PageLayoutTabMenuItemSelectAvatar } from '@/page-layout/components/PageLayoutTabMenuItemSelectAvatar'; import { PageLayoutTabRenderClone } from '@/page-layout/components/PageLayoutTabRenderClone'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { isPageLayoutTabDraggingComponentState } from '@/page-layout/states/isPageLayoutTabDraggingComponentState'; +import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar'; import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useContext } from 'react'; -import { MenuItemSelectAvatar } from 'twenty-ui/navigation'; const StyledOverflowDropdownListDraggableWrapper = styled.div` display: flex; @@ -60,6 +65,15 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ const context = useContext(TabListComponentInstanceContext); const instanceId = context?.instanceId; + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + ); + + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + pageLayoutId, + ); + const isTabDragging = useRecoilComponentValue( isPageLayoutTabDraggingComponentState, instanceId, @@ -70,6 +84,13 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ instanceId, ); + const setTabSettingsOpenTabId = useSetRecoilComponentState( + pageLayoutTabSettingsOpenTabIdComponentState, + pageLayoutId, + ); + + const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu(); + const handleClose = () => { if (!isTabDragging) { onClose(); @@ -82,6 +103,14 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({ handleClose(); }; + const handleEditClick = (tabId: string) => { + setTabSettingsOpenTabId(tabId); + navigatePageLayoutCommandMenu({ + commandMenuPage: CommandMenuPages.PageLayoutTabSettings, + }); + onClose(); + }; + return ( - } + handleTabSelect(tab.id) } disabled={disabled} + showEditButton={isPageLayoutInEditMode} + onEditClick={handleEditClick} /> diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx index 2aa59006814..105caf03027 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListReorderableTab.tsx @@ -1,6 +1,10 @@ import { Draggable } from '@hello-pangea/dnd'; +import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; import { StyledTabContainer, TabContent } from 'twenty-ui/input'; type PageLayoutTabListReorderableTabProps = { @@ -11,6 +15,15 @@ type PageLayoutTabListReorderableTabProps = { onSelect: () => void; }; +const StyledTabContent = styled(TabContent)<{ isBeingEdited: boolean }>` + ${({ isBeingEdited, theme }) => + isBeingEdited && + css` + border: 1px solid ${theme.color.blue}; + border-radius: ${theme.border.radius.sm}; + `} +`; + export const PageLayoutTabListReorderableTab = ({ tab, index, @@ -18,6 +31,11 @@ export const PageLayoutTabListReorderableTab = ({ disabled, onSelect, }: PageLayoutTabListReorderableTabProps) => { + const tabSettingsOpenTabId = useRecoilComponentValue( + pageLayoutTabSettingsOpenTabIdComponentState, + ); + + const isSettingsOpenForThisTab = tabSettingsOpenTabId === tab.id; return ( {(draggableProvided, draggableSnapshot) => ( @@ -35,7 +53,7 @@ export const PageLayoutTabListReorderableTab = ({ cursor: draggableSnapshot.isDragging ? 'grabbing' : 'pointer', }} > - )} diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx index 06befa5ecbd..ed52dec5f70 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListVisibleTabs.tsx @@ -34,10 +34,6 @@ const StyledTabContainer = styled.div` > *:not(:last-child) { margin-right: ${TAB_LIST_GAP}px; } - - // > div[data-rbd-placeholder-context-id] { - margin-right: ${TAB_LIST_GAP}px; - } `; export const PageLayoutTabListVisibleTabs = ({ diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabMenuItemSelectAvatar.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabMenuItemSelectAvatar.tsx new file mode 100644 index 00000000000..88104f37a15 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabMenuItemSelectAvatar.tsx @@ -0,0 +1,97 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { type MouseEvent, useState } from 'react'; + +import { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar'; +import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; +import { IconPencil } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { + StyledHoverableMenuItemBase, + StyledMenuItemIconCheck, + StyledMenuItemLabel, + StyledMenuItemLeftContent, +} from 'twenty-ui/navigation'; + +const StyledTextContainer = styled.div` + display: flex; + align-items: center; + flex: 1 0 0; + gap: ${({ theme }) => theme.spacing(1)}; + max-width: 100%; + text-overflow: ellipsis; + overflow: hidden; +`; + +const StyledRightContent = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +type PageLayoutTabMenuItemSelectAvatarProps = { + tab: SingleTabProps; + selected: boolean; + onClick?: (event?: MouseEvent) => void; + disabled?: boolean; + showEditButton?: boolean; + onEditClick?: (tabId: string) => void; + testId?: string; +}; + +export const PageLayoutTabMenuItemSelectAvatar = ({ + tab, + selected, + onClick, + disabled, + showEditButton = false, + onEditClick, + testId, +}: PageLayoutTabMenuItemSelectAvatarProps) => { + const theme = useTheme(); + const [isHovered, setIsHovered] = useState(false); + + const handleEditClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onEditClick?.(tab.id); + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + {tab.title} + + + + + {selected && !isHovered && ( + + )} + + {isHovered && showEditButton && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts index b57f8fd1559..a51d7a17206 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutTab.ts @@ -35,17 +35,21 @@ export const useCreatePageLayoutTab = (pageLayoutIdFromProps?: string) => { const createPageLayoutTab = useRecoilCallback( ({ snapshot, set }) => - (title?: string): void => { + (title?: string): string => { const pageLayoutDraft = snapshot .getLoadable(pageLayoutDraftState) .getValue(); const newTabId = uuidv4(); const tabsLength = pageLayoutDraft.tabs.length; + const maxPosition = + tabsLength > 0 + ? Math.max(...pageLayoutDraft.tabs.map((t) => t.position)) + : -1; const newTab: PageLayoutTab = { id: newTabId, title: title || `Tab ${tabsLength + 1}`, - position: tabsLength, + position: maxPosition + 1, pageLayoutId: pageLayoutId, widgets: [], createdAt: new Date().toISOString(), @@ -65,6 +69,8 @@ export const useCreatePageLayoutTab = (pageLayoutIdFromProps?: string) => { ); setActiveTabId(newTabId); + + return newTabId; }, [ pageLayoutCurrentLayoutsState, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutTab.ts new file mode 100644 index 00000000000..392f7eb26c8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDeletePageLayoutTab.ts @@ -0,0 +1,68 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; +import { removeTabLayouts } from '@/page-layout/utils/removeTabLayouts'; +import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; + +export const useDeletePageLayoutTab = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useRecoilComponentCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState( + pageLayoutCurrentLayoutsComponentState, + pageLayoutId, + ); + + const tabListInstanceId = getTabListInstanceIdFromPageLayoutId(pageLayoutId); + const activeTabIdState = useRecoilComponentCallbackState( + activeTabIdComponentState, + tabListInstanceId, + ); + + const deleteTab = useRecoilCallback( + ({ set, snapshot }) => + (tabId: string) => { + const draft = snapshot.getLoadable(pageLayoutDraftState).getValue(); + if (draft.tabs.length <= 1) { + return; + } + + const sorted = sortTabsByPosition(draft.tabs); + const index = sorted.findIndex((t) => t.id === tabId); + + const activeTabId = snapshot.getLoadable(activeTabIdState).getValue(); + + const allLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + const updatedLayouts = removeTabLayouts(allLayouts, tabId); + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: prev.tabs.filter((t) => t.id !== tabId), + })); + + if (activeTabId === tabId) { + const neighbor = index > 0 ? sorted[index - 1] : sorted[index + 1]; + const nextActiveId = neighbor?.id ?? null; + set(activeTabIdState, nextActiveId); + } + }, + [pageLayoutCurrentLayoutsState, pageLayoutDraftState, activeTabIdState], + ); + + return { deleteTab }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts new file mode 100644 index 00000000000..03a29f7b9f8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts @@ -0,0 +1,79 @@ +import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; + +export const useMovePageLayoutTab = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useRecoilComponentCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const moveLeft = useRecoilCallback( + ({ set }) => + (tabId: string) => { + set(pageLayoutDraftState, (prev) => { + const sorted = sortTabsByPosition(prev.tabs); + const index = sorted.findIndex((t) => t.id === tabId); + if (index <= 0) return prev; + + const items = sorted.filter((t) => t.id !== tabId); + const destinationIndex = index - 1; + const sourceIndex = index; + + const newPosition = calculateNewPosition({ + destinationIndex, + sourceIndex, + items, + }); + + return { + ...prev, + tabs: prev.tabs.map((t) => + t.id === tabId ? { ...t, position: newPosition } : t, + ), + }; + }); + }, + [pageLayoutDraftState], + ); + + const moveRight = useRecoilCallback( + ({ set }) => + (tabId: string) => { + set(pageLayoutDraftState, (prev) => { + const sorted = sortTabsByPosition(prev.tabs); + const index = sorted.findIndex((t) => t.id === tabId); + if (index < 0 || index >= sorted.length - 1) return prev; + + const items = sorted.filter((t) => t.id !== tabId); + const destinationIndex = index + 1; + const sourceIndex = index; + + const newPosition = calculateNewPosition({ + destinationIndex, + sourceIndex, + items, + }); + + return { + ...prev, + tabs: prev.tabs.map((t) => + t.id === tabId ? { ...t, position: newPosition } : t, + ), + }; + }); + }, + [pageLayoutDraftState], + ); + + return { moveLeft, moveRight }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useUpdatePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useUpdatePageLayoutTab.ts new file mode 100644 index 00000000000..5a889373b9f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useUpdatePageLayoutTab.ts @@ -0,0 +1,33 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; +import { type PageLayoutTab } from '../types/PageLayoutTab'; + +export const useUpdatePageLayoutTab = (pageLayoutIdFromProps?: string) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const pageLayoutDraftState = useRecoilComponentCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const updatePageLayoutTab = useRecoilCallback( + ({ set }) => + (tabId: string, updates: Partial) => { + set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: prev.tabs.map((tab) => + tab.id === tabId ? { ...tab, ...updates } : tab, + ), + })); + }, + [pageLayoutDraftState], + ); + + return { updatePageLayoutTab }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState.ts new file mode 100644 index 00000000000..5d652d5f509 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState.ts @@ -0,0 +1,10 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export const pageLayoutTabSettingsOpenTabIdComponentState = + createComponentState({ + key: 'pageLayoutTabSettingsOpenTabIdComponentState', + defaultValue: null, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/removeTabLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/removeTabLayouts.ts new file mode 100644 index 00000000000..a3a427a5849 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/removeTabLayouts.ts @@ -0,0 +1,13 @@ +import { type TabLayouts } from '@/page-layout/types/tab-layouts'; + +export const removeTabLayouts = ( + allTabLayouts: TabLayouts, + tabId: string, +): TabLayouts => { + if (!allTabLayouts[tabId]) { + return allTabLayouts; + } + + const { [tabId]: _removed, ...rest } = allTabLayouts; + return rest; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/sortTabsByPosition.ts b/packages/twenty-front/src/modules/page-layout/utils/sortTabsByPosition.ts new file mode 100644 index 00000000000..45f766b2a33 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/sortTabsByPosition.ts @@ -0,0 +1,5 @@ +export const sortTabsByPosition = ( + tabs: T[], +): T[] => { + return tabs.toSorted((a, b) => a.position - b.position); +}; diff --git a/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx b/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx index febb8900924..8025ef1d400 100644 --- a/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx +++ b/packages/twenty-ui/src/input/button/components/TabButton/internals/components/TabContent.tsx @@ -14,6 +14,7 @@ export type TabContentProps = { RightIcon?: IconComponent; pill?: string | ReactElement; contentSize?: 'sm' | 'md'; + className?: string; }; export const TabContent = ({ @@ -25,6 +26,7 @@ export const TabContent = ({ RightIcon, pill, contentSize = 'sm', + className, }: TabContentProps) => { const { theme } = useContext(ThemeContext); const iconColor = active @@ -34,7 +36,7 @@ export const TabContent = ({ : theme.font.color.secondary; return ( - + {LeftIcon && } {logo && } {title}