feat: new tab list (#12384)

closes #9904

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
nitin
2025-06-06 00:14:21 +05:30
committed by GitHub
parent a86b5fb9b2
commit 6f156a69b0
51 changed files with 1136 additions and 439 deletions

View File

@ -3,31 +3,25 @@ import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-men
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { AppPath } from '@/types/AppPath';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconBrowserMaximize } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const StyledLink = styled(Link)`
text-decoration: none;
`;
type RecordShowRightDrawerOpenRecordButtonProps = {
objectNameSingular: string;
@ -117,18 +111,15 @@ export const RecordShowRightDrawerOpenRecordButton = ({
return null;
}
const to = getLinkToShowPage(objectNameSingular, record);
return (
<StyledLink to={to} onClick={closeCommandMenu}>
<Button
title="Open"
variant="primary"
accent="blue"
size="medium"
Icon={IconBrowserMaximize}
hotkeys={[getOsControlSymbol(), '⏎']}
/>
</StyledLink>
<Button
title="Open"
variant="primary"
accent="blue"
size="medium"
Icon={IconBrowserMaximize}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={handleOpenRecord}
/>
);
};

View File

@ -7,8 +7,7 @@ import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableE
import { Task } from '@/activities/types/Task';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import groupBy from 'lodash.groupby';
import { IconPlus } from 'twenty-ui/display';
@ -23,6 +22,7 @@ import {
} from 'twenty-ui/layout';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
const StyledContainer = styled.div`
display: flex;

View File

@ -3,7 +3,7 @@ import { Meta, StoryObj } from '@storybook/react';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';

View File

@ -17,7 +17,7 @@ import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/code-action/constants/WorkflowServerlessFunctionTabListComponentId';
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowServerlessFunctionTabId';
import { useRecoilCallback } from 'recoil';

View File

@ -7,7 +7,7 @@ import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageI
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { isDefined } from 'twenty-shared/utils';
export const useCommandMenuHistory = () => {

View File

@ -1,8 +1,9 @@
import { getIsInputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsInputTabDisabled';
import { getIsOutputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsOutputTabDisabled';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';

View File

@ -5,8 +5,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { BASE_RECORD_LAYOUT } from '@/object-record/record-show/constants/BaseRecordLayout';
import { CardType } from '@/object-record/record-show/types/CardType';
import { RecordLayout } from '@/object-record/record-show/types/RecordLayout';
import { SingleTabProps } from '@/ui/layout/tab/components/TabList';
import { RecordLayoutTab } from '@/ui/layout/tab/types/RecordLayoutTab';
import { RecordLayoutTab } from '@/ui/layout/tab-list/types/RecordLayoutTab';
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';

View File

@ -1,4 +1,4 @@
import { RecordLayoutTab } from '@/ui/layout/tab/types/RecordLayoutTab';
import { RecordLayoutTab } from '@/ui/layout/tab-list/types/RecordLayoutTab';
export type RecordLayout = {
hideSummaryAndFields?: boolean;

View File

@ -10,8 +10,8 @@ import { SettingsAccountsCalendarChannelDetails } from '@/settings/accounts/comp
import { SettingsAccountsCalendarChannelsGeneral } from '@/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountCalendarChannelsTabListComponentId';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import React from 'react';

View File

@ -9,8 +9,8 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountMessageChannelsTabListComponentId';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import React from 'react';

View File

@ -2,7 +2,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { SettingsAdminTabContent } from '@/settings/admin-panel/components/SettingsAdminTabContent';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconHeart, IconSettings2, IconVariable } from 'twenty-ui/display';

View File

@ -4,7 +4,7 @@ import { userLookupResultState } from '@/settings/admin-panel/states/userLookupR
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@ -18,10 +18,9 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { getImageAbsoluteURI, isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import {
H2Title,
IconId,
@ -29,6 +28,7 @@ import {
IconSearch,
IconUser,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
const StyledContainer = styled.div`

View File

@ -3,7 +3,7 @@ import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-vari
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const SettingsAdminTabContent = () => {

View File

@ -14,8 +14,8 @@ import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { getOperationName } from '@apollo/client/utilities';

View File

@ -1,6 +1,6 @@
import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil';

View File

@ -1,7 +1,7 @@
import { SETTINGS_ROLE_DETAIL_TABS } from '@/settings/roles/role/constants/SettingsRoleDetailTabs';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect, useState } from 'react';

View File

@ -5,22 +5,22 @@ import {
import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { Button, CoreEditorHeader } from 'twenty-ui/input';
import {
H2Title,
IconGitCommit,
IconPlayerPlay,
IconRestore,
} from 'twenty-ui/display';
import { Button, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledTabList = styled(TabList)`
border-bottom: none;

View File

@ -9,9 +9,11 @@ import { RecordLayout } from '@/object-record/record-show/types/RecordLayout';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -28,7 +30,13 @@ const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>`
`;
const StyledTabListContainer = styled.div<{ shouldDisplay: boolean }>`
display: ${({ shouldDisplay }) => (shouldDisplay ? 'flex' : 'none')};
${({ shouldDisplay }) =>
!shouldDisplay &&
`
visibility: hidden;
height: 0;
overflow: hidden;
`}
`;
const StyledTabList = styled(TabList)`

View File

@ -0,0 +1,23 @@
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useTheme } from '@emotion/react';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
export const TabAvatar = ({ tab }: { tab: SingleTabProps }) => {
const theme = useTheme();
if (isDefined(tab.logo)) {
return <Avatar avatarUrl={tab.logo} size="md" placeholder={tab.title} />;
}
return (
tab.Icon && (
<tab.Icon
size={theme.icon.size.md}
color={
tab.disabled ? theme.font.color.tertiary : theme.font.color.secondary
}
stroke={theme.icon.stroke.md}
/>
)
);
};

View File

@ -0,0 +1,234 @@
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { TabListProps } from '@/ui/layout/tab-list/types/TabListProps';
import { TabWidthsById } from '@/ui/layout/tab-list/types/TabWidthsById';
import { calculateVisibleTabCount } from '@/ui/layout/tab-list/utils/calculateVisibleTabCount';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import styled from '@emotion/styled';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TabButton } from 'twenty-ui/input';
import { TabListDropdown } from './TabListDropdown';
import { TabListFromUrlOptionalEffect } from './TabListFromUrlOptionalEffect';
import { TabMoreButton } from './TabMoreButton';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
height: ${({ theme }) => theme.spacing(10)};
position: relative;
user-select: none;
width: 100%;
&::after {
background-color: ${({ theme }) => theme.border.color.light};
bottom: 0;
content: '';
height: 1px;
left: 0;
position: absolute;
right: 0;
}
`;
const StyledTabContainer = styled.div`
display: flex;
gap: ${TAB_LIST_GAP}px;
position: relative;
overflow: hidden;
max-width: 100%;
`;
const StyledHiddenMeasurement = styled.div`
display: flex;
gap: ${TAB_LIST_GAP}px;
pointer-events: none;
position: absolute;
top: -9999px;
visibility: hidden;
`;
export const TabList = ({
tabs,
loading,
behaveAsLinks = true,
isInRightDrawer,
className,
componentInstanceId,
}: TabListProps) => {
const visibleTabs = tabs.filter((tab) => !tab.hide);
const navigate = useNavigate();
const [activeTabId, setActiveTabId] = useRecoilComponentStateV2(
activeTabIdComponentState,
componentInstanceId,
);
const [tabWidthsById, setTabWidthsById] = useState<TabWidthsById>({});
const [containerWidth, setContainerWidth] = useState(0);
const [moreButtonWidth, setMoreButtonWidth] = useState(0);
const initialActiveTabId = activeTabId || visibleTabs[0]?.id || '';
const visibleTabCount = useMemo(() => {
return calculateVisibleTabCount({
visibleTabs,
tabWidthsById,
containerWidth,
moreButtonWidth,
});
}, [tabWidthsById, containerWidth, moreButtonWidth, visibleTabs]);
const hiddenTabsCount = visibleTabs.length - visibleTabCount;
const hasHiddenTabs = hiddenTabsCount > 0;
const dropdownId = `tab-overflow-${componentInstanceId}`;
const { closeDropdown } = useDropdown(dropdownId);
const isActiveTabHidden = useMemo(() => {
if (!hasHiddenTabs) return false;
const hiddenTabs = visibleTabs.slice(visibleTabCount);
return hiddenTabs.some((tab) => tab.id === activeTabId);
}, [hasHiddenTabs, visibleTabs, visibleTabCount, activeTabId]);
useEffect(() => {
setActiveTabId(initialActiveTabId);
}, [initialActiveTabId, setActiveTabId]);
const handleTabSelect = useCallback(
(tabId: string) => {
setActiveTabId(tabId);
},
[setActiveTabId],
);
const handleTabSelectFromDropdown = useCallback(
(tabId: string) => {
if (behaveAsLinks) {
navigate(`#${tabId}`);
} else {
handleTabSelect(tabId);
}
},
[behaveAsLinks, handleTabSelect, navigate],
);
const handleTabWidthChange = useCallback(
(tabId: string) => (dimensions: { width: number; height: number }) => {
setTabWidthsById((prev) => {
if (prev[tabId] !== dimensions.width) {
return {
...prev,
[tabId]: dimensions.width,
};
}
return prev;
});
},
[],
);
const handleContainerWidthChange = useCallback(
(dimensions: { width: number; height: number }) => {
setContainerWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
const handleMoreButtonWidthChange = useCallback(
(dimensions: { width: number; height: number }) => {
setMoreButtonWidth((prev) => {
return prev !== dimensions.width ? dimensions.width : prev;
});
},
[],
);
if (visibleTabs.length === 0) {
return null;
}
return (
<TabListComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<>
<TabListFromUrlOptionalEffect
isInRightDrawer={!!isInRightDrawer}
tabListIds={tabs.map((tab) => tab.id)}
/>
{visibleTabs.length > 1 && (
<StyledHiddenMeasurement>
{visibleTabs.map((tab) => (
<NodeDimension
key={tab.id}
onDimensionChange={handleTabWidthChange(tab.id)}
>
<TabButton
id={tab.id}
title={tab.title}
LeftIcon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}
disableTestId={true}
/>
</NodeDimension>
))}
<NodeDimension onDimensionChange={handleMoreButtonWidthChange}>
<TabMoreButton hiddenTabsCount={1} active={false} />
</NodeDimension>
</StyledHiddenMeasurement>
)}
<NodeDimension onDimensionChange={handleContainerWidthChange}>
<StyledContainer className={className}>
<StyledTabContainer>
{visibleTabs.slice(0, visibleTabCount).map((tab) => (
<TabButton
key={tab.id}
id={tab.id}
title={tab.title}
LeftIcon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}
to={behaveAsLinks ? `#${tab.id}` : undefined}
onClick={
behaveAsLinks ? undefined : () => handleTabSelect(tab.id)
}
/>
))}
</StyledTabContainer>
{hasHiddenTabs && (
<TabListDropdown
dropdownId={dropdownId}
onClose={() => {
closeDropdown();
}}
overflow={{
hiddenTabsCount,
isActiveTabHidden,
}}
hiddenTabs={visibleTabs.slice(visibleTabCount)}
activeTabId={activeTabId || ''}
onTabSelect={handleTabSelectFromDropdown}
loading={loading}
/>
)}
</StyledContainer>
</NodeDimension>
</>
</TabListComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,73 @@
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 { TabAvatar } from '@/ui/layout/tab-list/components/TabAvatar';
import { TabMoreButton } from '@/ui/layout/tab-list/components/TabMoreButton';
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
type TabListDropdownProps = {
dropdownId: string;
onClose: () => void;
overflow: {
hiddenTabsCount: number;
isActiveTabHidden: boolean;
};
hiddenTabs: SingleTabProps[];
activeTabId: string | null;
onTabSelect: (tabId: string) => void;
loading?: boolean;
};
export const TabListDropdown = ({
dropdownId,
onClose,
overflow,
hiddenTabs,
activeTabId,
onTabSelect,
loading,
}: TabListDropdownProps) => {
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
onClickOutside={onClose}
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<TabMoreButton
hiddenTabsCount={overflow.hiddenTabsCount}
active={overflow.isActiveTabHidden}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{hiddenTabs.map((tab) => {
const isDisabled = tab.disabled ?? loading;
return (
<MenuItemSelectAvatar
key={tab.id}
text={tab.title}
avatar={<TabAvatar tab={tab} />}
selected={tab.id === activeTabId}
onClick={
isDisabled
? undefined
: () => {
onTabSelect(tab.id);
onClose();
}
}
disabled={isDisabled}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownHotkeyScope={{ scope: dropdownId }}
/>
);
};

View File

@ -1,4 +1,4 @@
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect } from 'react';

View File

@ -0,0 +1,25 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconChevronDown } from 'twenty-ui/display';
import { TabButton } from 'twenty-ui/input';
const StyledTabMoreButton = styled(TabButton)`
height: ${({ theme }) => theme.spacing(10)};
`;
export const TabMoreButton = ({
hiddenTabsCount,
active,
}: {
hiddenTabsCount: number;
active: boolean;
}) => {
return (
<StyledTabMoreButton
id="tab-more-button"
active={active}
title={`+${hiddenTabsCount} ${t`More`}`}
RightIcon={IconChevronDown}
/>
);
};

View File

@ -0,0 +1,28 @@
import { Meta, StoryObj } from '@storybook/react';
import { TabMoreButton } from '../TabMoreButton';
const meta: Meta<typeof TabMoreButton> = {
title: 'UI/Layout/TabList/TabMoreButton',
component: TabMoreButton,
args: {
hiddenTabsCount: 3,
active: false,
},
};
export default meta;
type Story = StoryObj<typeof TabMoreButton>;
export const Default: Story = {
args: {
hiddenTabsCount: 3,
active: false,
},
};
export const Active: Story = {
args: {
hiddenTabsCount: 5,
active: true,
},
};

View File

@ -0,0 +1,82 @@
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import {
IconCalendar,
IconCheckbox,
IconHeart,
IconHome,
IconMail,
IconPhone,
IconUser,
} from 'twenty-ui/display';
import { ComponentWithRouterDecorator } from 'twenty-ui/testing';
import { TabList } from '../TabList';
const tabs = [
{ id: 'general', title: 'General', logo: 'https://picsum.photos/200' },
{ id: 'contacts', title: 'Contacts', Icon: IconUser },
{ id: 'messages', title: 'Messages', Icon: IconMail },
{ id: 'calls', title: 'Calls', Icon: IconPhone },
{ id: 'calendar', title: 'Calendar', Icon: IconCalendar },
{ id: 'sales', title: 'Sales', Icon: IconHome, disabled: true },
{ id: 'hidden', title: 'Hidden Tab', Icon: IconCheckbox, hide: true },
{
id: 'time',
title: 'Time Tracking',
logo: 'https://picsum.photos/192/192',
},
{
id: 'activity',
title: 'Activity',
logo: 'https://twenty-front-screenshots.s3.eu-west-3.amazonaws.com/server-icon.png',
disabled: true,
},
{ id: 'favorites', title: 'Favorites', Icon: IconHeart },
{ id: 'reports', title: 'Reports', Icon: IconCheckbox },
];
const StyledInteractiveContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.strong};
max-width: 100%;
min-width: 300px;
overflow: auto;
padding: ${({ theme }) => theme.spacing(5)};
resize: horizontal;
width: 600px;
`;
const meta: Meta<typeof TabList> = {
title: 'UI/Layout/TabList/TabList',
component: TabList,
args: {
tabs: tabs,
componentInstanceId: 'tab-list',
},
decorators: [ComponentWithRouterDecorator],
};
export default meta;
type Story = StoryObj<typeof TabList>;
export const Default: Story = {
args: {
tabs: tabs,
componentInstanceId: 'resizable-tabs',
},
render: (args) => (
<StyledInteractiveContainer>
<p>
<strong> Drag the bottom-right corner to resize!</strong>
</p>
<TabList
tabs={args.tabs}
componentInstanceId={args.componentInstanceId}
loading={args.loading}
behaveAsLinks={args.behaveAsLinks}
isInRightDrawer={args.isInRightDrawer}
className={args.className}
/>
</StyledInteractiveContainer>
),
};

View File

@ -0,0 +1 @@
export const TAB_LIST_GAP = 4;

View File

@ -0,0 +1 @@
export const TAB_LIST_LEFT_PADDING = 8;

View File

@ -1,4 +1,4 @@
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const activeTabIdComponentState = createComponentStateV2<string | null>({

View File

@ -1,5 +1,5 @@
import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard';
import { TabVisibilityConfig } from '@/ui/layout/tab/types/TabVisibilityConfig';
import { LayoutCard } from '@/ui/layout/tab-list/types/LayoutCard';
import { TabVisibilityConfig } from '@/ui/layout/tab-list/types/TabVisibilityConfig';
import { IconComponent } from 'twenty-ui/display';
export type RecordLayoutTab = {

View File

@ -0,0 +1,13 @@
import { LayoutCard } from '@/ui/layout/tab-list/types/LayoutCard';
import { IconComponent } from 'twenty-ui/display';
export type SingleTabProps<T extends string = string> = {
title: string;
Icon?: IconComponent;
id: T;
hide?: boolean;
disabled?: boolean;
pill?: string | React.ReactElement;
cards?: LayoutCard[];
logo?: string;
};

View File

@ -0,0 +1,10 @@
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
export type TabListProps = {
tabs: SingleTabProps[];
loading?: boolean;
behaveAsLinks?: boolean;
className?: string;
isInRightDrawer?: boolean;
componentInstanceId: string;
};

View File

@ -0,0 +1 @@
export type TabWidthsById = Record<string, number>;

View File

@ -0,0 +1,46 @@
import { TAB_LIST_GAP } from '@/ui/layout/tab-list/constants/TabListGap';
import { TAB_LIST_LEFT_PADDING } from '@/ui/layout/tab-list/constants/TabListPadding';
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { TabWidthsById } from '@/ui/layout/tab-list/types/TabWidthsById';
type CalculateVisibleTabCountParams = {
visibleTabs: SingleTabProps[];
tabWidthsById: TabWidthsById;
containerWidth: number;
moreButtonWidth: number;
};
export const calculateVisibleTabCount = ({
visibleTabs,
tabWidthsById,
containerWidth,
moreButtonWidth,
}: CalculateVisibleTabCountParams): number => {
if (Object.keys(tabWidthsById).length === 0 || containerWidth === 0) {
return visibleTabs.length;
}
const availableWidth = containerWidth - TAB_LIST_LEFT_PADDING;
let totalWidth = 0;
for (let i = 0; i < visibleTabs.length; i++) {
const tab = visibleTabs[i];
const tabWidth = tabWidthsById[tab.id];
// Skip if width not measured yet
if (tabWidth === undefined) {
return visibleTabs.length;
}
const gapsWidth = i > 0 ? TAB_LIST_GAP : 0;
const potentialMoreButtonWidth =
i < visibleTabs.length - 1 ? moreButtonWidth + TAB_LIST_GAP : 0;
totalWidth += tabWidth + gapsWidth;
if (totalWidth + potentialMoreButtonWidth > availableWidth) {
return Math.max(1, i);
}
}
return visibleTabs.length;
};

View File

@ -1,139 +0,0 @@
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import isPropValid from '@emotion/is-prop-valid';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Pill } from 'twenty-ui/components';
import { Avatar, IconComponent } from 'twenty-ui/display';
import { useMouseDownNavigation } from 'twenty-ui/utilities';
type TabProps = {
id: string;
title: string;
Icon?: IconComponent;
active?: boolean;
className?: string;
onClick?: () => void;
disabled?: boolean;
pill?: string | ReactElement;
to?: string;
logo?: string;
};
const StyledTab = styled('button', {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'active',
})<{ active?: boolean; disabled?: boolean; to?: string }>`
all: unset;
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-color: ${({ theme, active }) =>
active ? theme.border.color.inverted : 'transparent'};
color: ${({ theme, active, disabled }) =>
active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary};
cursor: pointer;
background-color: transparent;
border-left: none;
border-right: none;
border-top: none;
font-family: inherit;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
margin-bottom: -1px;
padding: ${({ theme }) => theme.spacing(2) + ' 0'};
pointer-events: ${({ disabled }) => (disabled ? 'none' : '')};
text-decoration: none;
`;
const StyledHover = styled.span`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm};
}
&:active {
background: ${({ theme }) => theme.background.quaternary};
}
`;
const StyledIconContainer = styled.div`
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
`;
export const Tab = ({
id,
title,
Icon,
active = false,
onClick,
className,
disabled,
pill,
to,
logo,
}: TabProps) => {
const theme = useTheme();
const { onClick: handleClick, onMouseDown: handleMouseDown } =
useMouseDownNavigation({
to,
onClick,
disabled,
});
const iconColor = active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary;
return (
<StyledTab
onClick={handleClick}
onMouseDown={handleMouseDown}
active={active}
className={className}
disabled={disabled}
data-testid={'tab-' + id}
as={to ? Link : 'button'}
to={to}
>
<StyledHover>
<StyledIconContainer>
{logo && (
<Avatar
avatarUrl={logo}
size="md"
placeholder={title}
iconColor={iconColor}
/>
)}
{Icon && (
<Avatar
Icon={Icon}
size="md"
placeholder={title}
iconColor={iconColor}
/>
)}
</StyledIconContainer>
<EllipsisDisplay>{title}</EllipsisDisplay>
{pill && typeof pill === 'string' ? <Pill label={pill} /> : pill}
</StyledHover>
</StyledTab>
);
};

View File

@ -1,110 +0,0 @@
import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab/components/TabListFromUrlOptionalEffect';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import styled from '@emotion/styled';
import React, { useEffect } from 'react';
import { IconComponent } from 'twenty-ui/display';
import { Tab } from './Tab';
export type SingleTabProps<T extends string = string> = {
title: string;
Icon?: IconComponent;
id: T;
hide?: boolean;
disabled?: boolean;
pill?: string | React.ReactElement;
cards?: LayoutCard[];
logo?: string;
};
type TabListProps = {
tabs: SingleTabProps[];
loading?: boolean;
behaveAsLinks?: boolean;
className?: string;
isInRightDrawer?: boolean;
componentInstanceId: string;
};
const StyledContainer = styled.div`
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: 40px;
user-select: none;
width: 100%;
`;
const StyledOuterContainer = styled.div`
width: 100%;
`;
export const TabList = ({
tabs,
loading,
behaveAsLinks = true,
isInRightDrawer,
className,
componentInstanceId,
}: TabListProps) => {
const visibleTabs = tabs.filter((tab) => !tab.hide);
const [activeTabId, setActiveTabId] = useRecoilComponentStateV2(
activeTabIdComponentState,
componentInstanceId,
);
const initialActiveTabId = activeTabId || visibleTabs[0]?.id || '';
useEffect(() => {
setActiveTabId(initialActiveTabId);
}, [initialActiveTabId, setActiveTabId]);
if (visibleTabs.length <= 1) {
return null;
}
return (
<TabListComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<StyledOuterContainer>
<TabListFromUrlOptionalEffect
isInRightDrawer={!!isInRightDrawer}
tabListIds={tabs.map((tab) => tab.id)}
/>
<ScrollWrapper
defaultEnableYScroll={false}
componentInstanceId={`scroll-wrapper-tab-list-${componentInstanceId}`}
>
<StyledContainer className={className}>
{visibleTabs.map((tab) => (
<Tab
id={tab.id}
key={tab.id}
title={tab.title}
Icon={tab.Icon}
logo={tab.logo}
active={tab.id === activeTabId}
disabled={tab.disabled ?? loading}
pill={tab.pill}
to={behaveAsLinks ? `#${tab.id}` : undefined}
onClick={
behaveAsLinks
? undefined
: () => {
setActiveTabId(tab.id);
}
}
/>
))}
</StyledContainer>
</ScrollWrapper>
</StyledOuterContainer>
</TabListComponentInstanceContext.Provider>
);
};

View File

@ -1,65 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox } from 'twenty-ui/display';
import {
CatalogDecorator,
CatalogStory,
ComponentWithRouterDecorator,
} from 'twenty-ui/testing';
import { Tab } from '../Tab';
const meta: Meta<typeof Tab> = {
title: 'UI/Layout/Tab/Tab',
component: Tab,
decorators: [ComponentWithRouterDecorator],
};
export default meta;
type Story = StoryObj<typeof Tab>;
export const Default: Story = {
args: {
title: 'Tab title',
active: false,
Icon: IconCheckbox,
disabled: false,
},
decorators: [ComponentWithRouterDecorator],
};
export const Catalog: CatalogStory<Story, typeof Tab> = {
args: { title: 'Tab title', Icon: IconCheckbox },
argTypes: {
active: { control: false },
disabled: { control: false },
onClick: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'] },
catalog: {
dimensions: [
{
name: 'states',
values: ['default', 'hover', 'active'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
{
name: 'Active',
values: ['true', 'false'],
labels: (active: string) =>
active === 'true' ? 'active' : 'inactive',
props: (active: string) => ({ active: active === 'true' }),
},
{
name: 'Disabled',
values: ['true', 'false'],
labels: (disabled: string) =>
disabled === 'true' ? 'disabled' : 'enabled',
props: (disabled: string) => ({ disabled: disabled === 'true' }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -1,60 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { TabList } from '../TabList';
import { ComponentWithRouterDecorator } from 'twenty-ui/testing';
import { IconCheckbox } from 'twenty-ui/display';
const tabs = [
{
id: '1',
title: 'Tab1',
Icon: IconCheckbox,
hide: true,
},
{
id: '2',
title: 'Tab2',
Icon: IconCheckbox,
hide: false,
},
{
id: '3',
title: 'Tab3',
Icon: IconCheckbox,
hide: false,
disabled: true,
},
{
id: '4',
title: 'Tab4',
Icon: IconCheckbox,
hide: false,
disabled: false,
},
];
const meta: Meta<typeof TabList> = {
title: 'UI/Layout/Tab/TabList',
component: TabList,
args: {
tabs: tabs,
componentInstanceId: 'tab-list',
},
decorators: [ComponentWithRouterDecorator],
};
export default meta;
type Story = StoryObj<typeof TabList>;
export const TabListDisplay: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const submitButton = canvas.queryByText('Tab1');
expect(submitButton).toBeNull();
expect(await canvas.findByText('Tab2')).toBeInTheDocument();
expect(await canvas.findByText('Tab3')).toBeInTheDocument();
expect(await canvas.findByText('Tab4')).toBeInTheDocument();
},
};

View File

@ -0,0 +1,34 @@
import { ReactNode, useEffect, useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
type NodeDimensionProps = {
children: ReactNode;
onDimensionChange: (dimensions: { width: number; height: number }) => void;
};
export const NodeDimension = ({
children,
onDimensionChange,
}: NodeDimensionProps) => {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!elementRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (isDefined(entry)) {
onDimensionChange({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
resizeObserver.observe(elementRef.current);
return () => resizeObserver.disconnect();
}, [onDimensionChange]);
return <div ref={elementRef}>{children}</div>;
};

View File

@ -1,7 +1,7 @@
import { getIsInputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsInputTabDisabled';
import { getIsOutputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsOutputTabDisabled';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowRunTabId } from '@/workflow/workflow-steps/types/WorkflowRunTabId';

View File

@ -18,8 +18,8 @@ import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-function
import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextArea } from '@/ui/input/components/TextArea';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';

View File

@ -12,7 +12,7 @@ import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isReco
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowDiagramTriggerNodeSelectionComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramTriggerNodeSelectionComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';

View File

@ -11,13 +11,13 @@ import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLab
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';

View File

@ -11,16 +11,16 @@ import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { isDefined } from 'twenty-shared/utils';
import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui/display';
const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail';

View File

@ -0,0 +1,122 @@
import styled from '@emotion/styled';
import { Pill } from '@ui/components/Pill/Pill';
import { Avatar, IconComponent } from '@ui/display';
import { ThemeContext } from '@ui/theme';
import { ReactElement, useContext } from 'react';
import { Link } from 'react-router-dom';
const StyledTabButton = styled.button<{
active?: boolean;
disabled?: boolean;
to?: string;
}>`
all: unset;
align-items: center;
color: ${({ theme, active, disabled }) =>
active
? theme.font.color.primary
: disabled
? theme.font.color.light
: theme.font.color.secondary};
cursor: pointer;
background-color: transparent;
border: none;
font-family: inherit;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
pointer-events: ${({ disabled }) => (disabled ? 'none' : '')};
text-decoration: none;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: ${({ theme, active }) =>
active ? theme.border.color.inverted : 'transparent'};
z-index: 1;
}
`;
const StyledTabHover = styled.span<{
contentSize?: 'sm' | 'md';
}>`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme, contentSize }) =>
contentSize === 'sm'
? `${theme.spacing(1)} ${theme.spacing(2)}`
: `${theme.spacing(2)} ${theme.spacing(2)}`};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
white-space: nowrap;
border-radius: ${({ theme }) => theme.border.radius.sm};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
&:active {
background: ${({ theme }) => theme.background.quaternary};
}
`;
type TabButtonProps = {
id: string;
active?: boolean;
disabled?: boolean;
to?: string;
LeftIcon?: IconComponent;
className?: string;
title?: string;
onClick?: () => void;
logo?: string;
RightIcon?: IconComponent;
pill?: string | ReactElement;
contentSize?: 'sm' | 'md';
disableTestId?: boolean;
};
export const TabButton = ({
id,
active,
disabled,
to,
LeftIcon,
className,
title,
onClick,
logo,
RightIcon,
pill,
contentSize = 'sm',
disableTestId = false,
}: TabButtonProps) => {
const { theme } = useContext(ThemeContext);
const iconColor = active
? theme.font.color.primary
: disabled
? theme.font.color.extraLight
: theme.font.color.secondary;
return (
<StyledTabButton
data-testid={disableTestId ? undefined : `tab-${id}`}
active={active}
disabled={disabled}
as={to ? Link : 'button'}
to={to}
className={className}
onClick={onClick}
>
<StyledTabHover contentSize={contentSize}>
{LeftIcon && <LeftIcon color={iconColor} size={theme.icon.size.md} />}
{logo && <Avatar avatarUrl={logo} size="md" placeholder={title} />}
{title}
{RightIcon && <RightIcon color={iconColor} size={theme.icon.size.md} />}
{pill && (typeof pill === 'string' ? <Pill label={pill} /> : pill)}
</StyledTabHover>
</StyledTabButton>
);
};

View File

@ -0,0 +1,377 @@
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import {
IconCheckbox,
IconChevronDown,
IconMail,
IconSearch,
IconSettings,
IconUser,
} from '@ui/display';
import {
CatalogDecorator,
CatalogStory,
ComponentWithRouterDecorator,
RecoilRootDecorator,
} from '@ui/testing';
import { TabButton } from '../TabButton';
// Mimic the TabList container styling for proper positioning
const StyledTabContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: 40px;
user-select: none;
position: relative;
align-items: stretch;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: ${({ theme }) => theme.border.color.light};
}
`;
const meta: Meta<typeof TabButton> = {
title: 'UI/Input/Button/TabButton',
component: TabButton,
decorators: [ComponentWithRouterDecorator, RecoilRootDecorator],
args: {
id: 'tab-button',
title: 'Tab Title',
active: false,
disabled: false,
contentSize: 'sm',
},
argTypes: {
LeftIcon: { control: false },
RightIcon: { control: false },
pill: { control: 'text' },
contentSize: {
control: 'select',
options: ['sm', 'md'],
},
},
};
export default meta;
type Story = StoryObj<typeof TabButton>;
export const Default: Story = {
args: {
title: 'General',
LeftIcon: IconSettings,
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const Active: Story = {
args: {
title: 'Active Tab',
LeftIcon: IconUser,
active: true,
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const Disabled: Story = {
args: {
title: 'Disabled Tab',
LeftIcon: IconCheckbox,
disabled: true,
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const WithLogo: Story = {
args: {
title: 'Company',
logo: 'https://picsum.photos/192/192',
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const WithStringPill: Story = {
args: {
title: 'Messages',
LeftIcon: IconMail,
pill: '12',
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const WithBothIcons: Story = {
args: {
title: 'Search',
LeftIcon: IconSearch,
RightIcon: IconChevronDown,
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const AsLink: Story = {
args: {
title: 'Link Tab',
LeftIcon: IconUser,
to: '/profile',
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const SmallContent: Story = {
args: {
title: 'Small',
LeftIcon: IconSettings,
contentSize: 'sm',
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const MediumContent: Story = {
args: {
title: 'Medium',
LeftIcon: IconSettings,
contentSize: 'md',
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
};
export const Catalog: CatalogStory<Story, typeof TabButton> = {
args: {
title: 'Tab title',
LeftIcon: IconCheckbox,
},
argTypes: {
active: { control: false },
disabled: { control: false },
onClick: { control: false },
to: { control: false },
},
render: (args) => (
<StyledTabContainer>
<TabButton
id={args.id}
title={args.title}
LeftIcon={args.LeftIcon}
RightIcon={args.RightIcon}
active={args.active}
disabled={args.disabled}
pill={args.pill}
to={args.to}
logo={args.logo}
onClick={args.onClick}
className={args.className}
contentSize={args.contentSize}
/>
</StyledTabContainer>
),
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'] },
catalog: {
dimensions: [
{
name: 'states',
values: ['default', 'hover', 'active'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
{
name: 'State',
values: ['active', 'inactive', 'disabled'],
labels: (state: string) => state,
props: (state: string) => ({
active: state === 'active',
disabled: state === 'disabled',
}),
},
{
name: 'Content Size',
values: ['sm', 'md'],
labels: (size: string) => size,
props: (size: string) => ({ contentSize: size as 'sm' | 'md' }),
},
{
name: 'Content',
values: ['icon', 'logo', 'pill'],
props: (content: string) => {
switch (content) {
case 'icon':
return { LeftIcon: IconSettings };
case 'logo':
return {
logo: 'https://picsum.photos/192/192',
LeftIcon: undefined,
};
case 'pill':
return { LeftIcon: IconMail, pill: '5' };
default:
return {};
}
},
},
],
},
layout: 'centered',
viewport: {
defaultViewport: 'responsive',
},
},
decorators: [CatalogDecorator],
};

View File

@ -65,6 +65,7 @@ export { LightIconButtonGroup } from './button/components/LightIconButtonGroup';
export type { MainButtonVariant } from './button/components/MainButton';
export { MainButton } from './button/components/MainButton';
export { RoundedIconButton } from './button/components/RoundedIconButton';
export { TabButton } from './button/components/TabButton';
export { CodeEditor } from './code-editor/components/CodeEditor';
export type { CoreEditorHeaderProps } from './code-editor/components/CodeEditorHeader';
export { CoreEditorHeader } from './code-editor/components/CodeEditorHeader';