feat: move support links to workspace switcher (#16653)

## Summary
Move the Support and Documentation links from the bottom left navigation
drawer to the workspace switcher dropdown menu.

## Changes
- **Workspace Switcher**: Added Support (conditional) and Documentation
links before Log out
- **Settings Navigation**: Added Support and Documentation links in the
Other section
- **Support visibility**: Support link only appears when FrontChat is
configured (not waiting for script to load)
- **FrontChat loading**: Created `SupportChatEffect` component to ensure
FrontChat script loads at app startup for popup notifications
- **Bug fix**: Fixed settings navigation items without a path appearing
highlighted
- **Cleanup**: Removed unused `SupportDropdown`, `SupportButton`, and
`SupportButtonSkeletonLoader` components
- **Hidden**: Temporarily commented out Integrations page

## Screenshots
The Support and Documentation links now appear in:
1. Workspace switcher dropdown (top left)
2. Settings navigation (Other section)
This commit is contained in:
Félix Malfait
2025-12-18 10:09:10 +01:00
committed by GitHub
parent 4fe8e3d3b6
commit 12544da527
12 changed files with 100 additions and 195 deletions
@@ -21,6 +21,7 @@ import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { SupportChatEffect } from '@/support/components/SupportChatEffect';
import { UserAndViewsProviderEffect } from '@/users/components/UserAndViewsProviderEffect';
import { UserProvider } from '@/users/components/UserProvider';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
@@ -66,6 +67,7 @@ export const AppRouterProviders = () => {
</DialogComponentInstanceContext.Provider>
</SnackBarProvider>
<MainContextStoreProvider />
<SupportChatEffect />
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
@@ -5,7 +5,6 @@ import { FavoritesFolderContent } from '@/favorites/components/FavoritesFolderCo
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { MainNavigationDrawerFixedItems } from '@/navigation/components/MainNavigationDrawerFixedItems';
import { MainNavigationDrawerScrollableItems } from '@/navigation/components/MainNavigationDrawerScrollableItems';
import { SupportDropdown } from '@/support/components/SupportDropdown';
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent';
@@ -39,10 +38,6 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => {
<MainNavigationDrawerScrollableItems />
)}
</NavigationDrawerScrollableContent>
<NavigationDrawerFixedContent>
<SupportDropdown />
</NavigationDrawerFixedContent>
</NavigationDrawer>
);
};
@@ -24,7 +24,7 @@ export const SettingsNavigationDrawerItem = ({
end: item.matchSubPages === false,
});
const isActive = !!matchResult && !hasActiveSubItem;
const isActive = !!item.path && !!matchResult && !hasActiveSubItem;
if (isDefined(item.isHidden) && item.isHidden) {
return null;
@@ -37,7 +37,7 @@ export const SettingsNavigationDrawerItem = ({
indentationLevel={item.indentationLevel}
subItemState={subItemState}
label={item.label}
to={href}
to={href || undefined}
Icon={item.Icon}
active={isActive}
soon={item.soon}
@@ -53,7 +53,7 @@ export const SettingsNavigationDrawerItem = ({
indentationLevel={item.indentationLevel}
subItemState={subItemState}
label={item.label}
to={href}
to={href || undefined}
Icon={item.Icon}
active={isActive}
soon={item.soon}
@@ -2,25 +2,32 @@ import { SettingsPath } from 'twenty-shared/types';
import { useAuth } from '@/auth/hooks/useAuth';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { billingState } from '@/client-config/states/billingState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { type NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import {
IconApi,
IconApps,
// IconApps, // TODO: Re-enable when integrations page is ready
IconAt,
IconCalendarEvent,
IconColorSwatch,
type IconComponent,
IconCurrencyDollar,
IconDoorEnter,
IconHelpCircle,
IconHierarchy2,
IconKey,
IconLock,
IconMail,
IconMessage,
IconPlug,
IconRocket,
IconServer,
IconSettings,
@@ -28,7 +35,6 @@ import {
IconUserCircle,
IconUsers,
IconWorld,
IconPlug,
} from 'twenty-ui/display';
import { FeatureFlagKey, PermissionFlagType } from '~/generated/graphql';
@@ -55,6 +61,8 @@ export type SettingsNavigationItem = {
const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
const billing = useRecoilValue(billingState);
const { signOut } = useAuth();
const supportChat = useRecoilValue(supportChatState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const currentUser = useRecoilValue(currentUserState);
@@ -65,6 +73,9 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
const isApplicationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_APPLICATION_ENABLED,
);
const isSupportChatConfigured =
supportChat?.supportDriver === 'FRONT' &&
isNonEmptyString(supportChat.supportFrontChatId);
const permissionMap = usePermissionFlagMap();
return [
@@ -148,12 +159,13 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconApi,
isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS],
},
{
label: t`Integrations`,
path: SettingsPath.Integrations,
Icon: IconApps,
isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS],
},
// TODO: Re-enable when integrations page is ready
// {
// label: t`Integrations`,
// path: SettingsPath.Integrations,
// Icon: IconApps,
// isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS],
// },
{
label: t`Applications`,
path: SettingsPath.Applications,
@@ -195,6 +207,21 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconRocket,
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Support`,
onClick: () => window.FrontChat?.('show'),
Icon: IconMessage,
isHidden: !isSupportChatConfigured,
},
{
label: t`Documentation`,
onClick: () =>
window.open(
getDocumentationUrl(currentWorkspaceMember?.locale),
'_blank',
),
Icon: IconHelpCircle,
},
{
label: t`Logout`,
onClick: signOut,
@@ -1,6 +1,4 @@
import { SupportDropdown } from '@/support/components/SupportDropdown';
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -63,9 +61,6 @@ export const SignInAppNavigationDrawerMock = ({
WORKSPACE_FAVORITES.includes(item.nameSingular),
)}
/>
<NavigationDrawerFixedContent>
<SupportDropdown />
</NavigationDrawerFixedContent>
</NavigationDrawer>
);
};
@@ -1,41 +0,0 @@
import styled from '@emotion/styled';
import { SupportButtonSkeletonLoader } from '@/support/components/SupportButtonSkeletonLoader';
import { useSupportChat } from '@/support/hooks/useSupportChat';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconHelpCircle } from 'twenty-ui/display';
import { Button, LightIconButton } from 'twenty-ui/input';
const StyledButtonContainer = styled.div`
display: flex;
`;
export const SupportButton = () => {
const { loading, isFrontChatLoaded } = useSupportChat();
const isNavigationDrawerExpanded = useRecoilValue(
isNavigationDrawerExpandedState,
);
if (loading) {
return <SupportButtonSkeletonLoader />;
}
if (!isFrontChatLoaded) {
return;
}
return isNavigationDrawerExpanded ? (
<StyledButtonContainer>
<Button
variant="tertiary"
size="small"
title={t`Support`}
Icon={IconHelpCircle}
/>
</StyledButtonContainer>
) : (
<LightIconButton Icon={IconHelpCircle} />
);
};
@@ -1,16 +0,0 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
export const SupportButtonSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton width={84} height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
</SkeletonTheme>
);
};
@@ -0,0 +1,7 @@
import { useSupportChat } from '@/support/hooks/useSupportChat';
export const SupportChatEffect = () => {
useSupportChat();
return null;
};
@@ -1,50 +0,0 @@
import { SupportButton } from '@/support/components/SupportButton';
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 { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { t } from '@lingui/core/macro';
import { IconHelpCircle, IconMessage } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export const SupportDropdown = () => {
const dropdownId = `support-field-active-action-dropdown`;
const { closeDropdown } = useCloseDropdown();
const handleTalkToUs = () => {
window.FrontChat?.('show');
closeDropdown(dropdownId);
};
const handleUserGuide = () => {
window.open('https://docs.twenty.com/user-guide/introduction', '_blank');
closeDropdown(dropdownId);
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="top-start"
dropdownOffset={{ x: 0, y: -28 }}
clickableComponent={<SupportButton />}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Talk to us`}
LeftIcon={IconMessage}
onClick={handleTalkToUs}
/>
<MenuItem
text={t`Documentation`}
LeftIcon={IconHelpCircle}
onClick={handleUserGuide}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -1,67 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockCurrentWorkspace,
mockedUserData,
mockedWorkspaceMemberData,
} from '~/testing/mock-data/users';
import { SupportDropdown } from '@/support/components/SupportDropdown';
import { SupportDriver } from '~/generated-metadata/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { PrefetchLoadedDecorator } from '~/testing/decorators/PrefetchLoadedDecorator';
const meta: Meta<typeof SupportDropdown> = {
title: 'Modules/Support/SupportDropdown',
component: SupportDropdown,
decorators: [
I18nFrontDecorator,
(Story) => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setSupportChat = useSetRecoilState(supportChatState);
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
setCurrentWorkspace(mockCurrentWorkspace);
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
setCurrentUser(mockedUserData);
setSupportChat({
supportDriver: SupportDriver.FRONT,
supportFrontChatId: '1234',
});
return <Story />;
},
PrefetchLoadedDecorator,
],
parameters: {
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SupportDropdown>;
export const Default: Story = {
play: async () => {
const canvas = within(document.body);
expect(await canvas.findByText('Support')).toBeInTheDocument();
await userEvent.click(canvas.getByText('Support'));
// Add delay to account for the timeout in useSupportChat
await new Promise((resolve) => setTimeout(resolve, 600));
expect(await canvas.findByText('Documentation')).toBeInTheDocument();
expect(await canvas.findByText('Talk to us')).toBeInTheDocument();
},
};
@@ -0,0 +1,20 @@
const DOCUMENTATION_BASE_URL = 'https://docs.twenty.com';
const DOCUMENTATION_PATH = '/user-guide/introduction';
// Locales that have documentation translations available
const SUPPORTED_DOC_LOCALES = ['fr', 'pt', 'de', 'es', 'it', 'ja', 'ko', 'zh'];
export const getDocumentationUrl = (locale?: string | null): string => {
if (!locale) {
return `${DOCUMENTATION_BASE_URL}${DOCUMENTATION_PATH}`;
}
// Extract language code from locale (e.g., 'fr' from 'fr-FR')
const langCode = locale.split('-')[0].toLowerCase();
if (SUPPORTED_DOC_LOCALES.includes(langCode)) {
return `${DOCUMENTATION_BASE_URL}/l/${langCode}${DOCUMENTATION_PATH}`;
}
return `${DOCUMENTATION_BASE_URL}${DOCUMENTATION_PATH}`;
};
@@ -2,10 +2,13 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import { useAuth } from '@/auth/hooks/useAuth';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { supportChatState } from '@/client-config/states/supportChatState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -19,6 +22,7 @@ import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/s
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { type ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { AppPath, SettingsPath } from 'twenty-shared/types';
@@ -26,7 +30,9 @@ import { getSettingsPath } from 'twenty-shared/utils';
import {
Avatar,
IconDotsVertical,
IconHelpCircle,
IconLogout,
IconMessage,
IconPlus,
IconSwitchHorizontal,
IconUserPlus,
@@ -50,6 +56,7 @@ const StyledDescription = styled.div`
export const MultiWorkspaceDropdownDefaultComponents = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { t } = useLingui();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const availableWorkspaces = useRecoilValue(availableWorkspacesState);
@@ -60,6 +67,10 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
const { signOut } = useAuth();
const { enqueueErrorSnackBar } = useSnackBar();
const { colorScheme, colorSchemeList } = useColorScheme();
const supportChat = useRecoilValue(supportChatState);
const isSupportChatConfigured =
supportChat?.supportDriver === 'FRONT' &&
isNonEmptyString(supportChat.supportFrontChatId);
const [signUpInNewWorkspaceMutation] = useSignUpInNewWorkspaceMutation();
@@ -67,6 +78,16 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
multiWorkspaceDropdownState,
);
const handleSupport = () => {
window.FrontChat?.('show');
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
};
const handleDocumentation = () => {
window.open(getDocumentationUrl(currentWorkspaceMember?.locale), '_blank');
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
};
const handleChange = async (availableWorkspace: AvailableWorkspace) => {
redirectToWorkspaceDomain(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
@@ -200,6 +221,18 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
>
<MenuItem LeftIcon={IconUserPlus} text={t`Invite user`} />
</UndecoratedLink>
{isSupportChatConfigured && (
<MenuItem
LeftIcon={IconMessage}
text={t`Support`}
onClick={handleSupport}
/>
)}
<MenuItem
LeftIcon={IconHelpCircle}
text={t`Documentation`}
onClick={handleDocumentation}
/>
<MenuItem LeftIcon={IconLogout} text={t`Log out`} onClick={signOut} />
</DropdownMenuItemsContainer>
</DropdownContent>