Fix side panel command menu header controls (#21747)
## Summary Tested the 3 behaviors of the issue locally. Animation is not perfect on the closing but I think this is a great v1 - Move the side-panel close action to the right side of the top bar while keeping back navigation on the left. - Keep the nav side-panel button as the command-menu entry point for direct side-panel pages and hide it while command-menu pages/history are active. - Reset command-menu search/filter state when opening the root command menu from the nav button. Fixes twentyhq/core-team-issues#2504 ## Videos ### Before https://github.com/user-attachments/assets/08c1b6b3-5fbd-4154-a85d-5072a3b7690e ### After https://github.com/user-attachments/assets/11682dea-f21c-47b5-91a8-869f30b09d96 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21747?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
cc4659ce11
commit
454758471f
+1
-7
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Tag } from 'twenty-ui/data-display';
|
||||
import {
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
import { H2Title } from 'twenty-ui/typography';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { type ThemeColor } from 'twenty-ui/theme';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
|
||||
import { GET_WORKSPACE_BILLING_ADMIN_PANEL } from '@/settings/admin-panel/graphql/queries/getWorkspaceBillingAdminPanel';
|
||||
@@ -75,10 +74,6 @@ const StyledItemValue = styled.div`
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledProgressBarContainer = styled.div`
|
||||
margin-bottom: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const STATUS_COLORS: Record<SubscriptionStatus, ThemeColor> = {
|
||||
[SubscriptionStatus.Active]: 'green',
|
||||
[SubscriptionStatus.Trialing]: 'blue',
|
||||
@@ -143,7 +138,6 @@ export const SettingsAdminWorkspaceBillingContent = ({
|
||||
}: SettingsAdminWorkspaceBillingContentProps) => {
|
||||
const { t } = useLingui();
|
||||
const { formatNumber } = useNumberFormat();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const apolloAdminClient = useApolloAdminClient();
|
||||
|
||||
const { data, loading } = useQuery<WorkspaceBillingAdminPanelQuery>(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconChevronLeft } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
@@ -54,6 +55,7 @@ export const SidePanelBackButton = () => {
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={goBackFromSidePanel}
|
||||
ariaLabel={t`Back`}
|
||||
/>
|
||||
</StyledNavigationIcon>
|
||||
}
|
||||
|
||||
+30
-121
@@ -1,20 +1,20 @@
|
||||
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
|
||||
import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useContext } from 'react';
|
||||
import { IconDotsVertical } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/surfaces';
|
||||
import { AnimatedButton } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledButtonWrapper = styled.div<{ alignToTop: boolean }>`
|
||||
align-items: ${({ alignToTop }) => (alignToTop ? 'center' : 'initial')};
|
||||
@@ -32,100 +32,13 @@ const StyledTooltipWrapper = styled.div`
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
`;
|
||||
|
||||
const xPaths = {
|
||||
topLeft: `M12 12 L6 6`,
|
||||
topRight: `M12 12 L18 6`,
|
||||
bottomLeft: `M12 12 L6 18`,
|
||||
bottomRight: `M12 12 L18 18`,
|
||||
};
|
||||
|
||||
const AnimatedIcon = ({
|
||||
isSidePanelOpened,
|
||||
}: {
|
||||
isSidePanelOpened: boolean;
|
||||
}) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={theme.icon.size.sm}
|
||||
height={theme.icon.size.sm}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={theme.icon.stroke.md}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<motion.circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r="1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
scale: isSidePanelOpened ? 0 : 1,
|
||||
opacity: isSidePanelOpened ? 0 : 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.fast,
|
||||
}}
|
||||
/>
|
||||
|
||||
{Object.values(xPaths).map((path, index) => (
|
||||
<motion.path
|
||||
key={index}
|
||||
d={path}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{
|
||||
pathLength: isSidePanelOpened ? 1 : 0,
|
||||
opacity: isSidePanelOpened ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.fast,
|
||||
ease: 'easeInOut',
|
||||
delay: isSidePanelOpened ? 0.1 : 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<motion.circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
r="1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
scale: isSidePanelOpened ? 0 : 1,
|
||||
opacity: isSidePanelOpened ? 0 : 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.fast,
|
||||
}}
|
||||
/>
|
||||
|
||||
<motion.circle
|
||||
cx="12"
|
||||
cy="19"
|
||||
r="1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
scale: isSidePanelOpened ? 0 : 1,
|
||||
opacity: isSidePanelOpened ? 0 : 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.fast,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidePanelToggleButton = () => {
|
||||
const { toggleSidePanelMenu } = useSidePanelMenu();
|
||||
const { openSidePanelMenu } = useSidePanelMenu();
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||
const sidePanelNavigationStack = useAtomStateValue(
|
||||
sidePanelNavigationStackState,
|
||||
);
|
||||
const isLayoutCustomizationModeEnabled = useAtomStateValue(
|
||||
isLayoutCustomizationModeEnabledState,
|
||||
);
|
||||
@@ -133,43 +46,39 @@ export const SidePanelToggleButton = () => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isCommandMenuOpened =
|
||||
isSidePanelOpened &&
|
||||
[
|
||||
SidePanelPages.CommandMenuDisplay,
|
||||
SidePanelPages.CommandMenuEdit,
|
||||
SidePanelPages.SearchRecords,
|
||||
].includes(sidePanelPage);
|
||||
|
||||
const showAsOpen = isSidePanelOpened && !isCommandMenuOpened;
|
||||
isSidePanelOpened && COMMAND_MENU_SIDE_PANEL_PAGES.includes(sidePanelPage);
|
||||
|
||||
const alignWithSidePanelTopBar =
|
||||
isMobile && isLayoutCustomizationModeEnabled && isSidePanelOpened;
|
||||
|
||||
const ariaLabel = isSidePanelOpened
|
||||
? t`Close side panel`
|
||||
: t`Open side panel`;
|
||||
const hasCommandMenuPageInNavigationStack = sidePanelNavigationStack.some(
|
||||
({ page }) => COMMAND_MENU_SIDE_PANEL_PAGES.includes(page),
|
||||
);
|
||||
|
||||
const shouldHideButton =
|
||||
isCommandMenuOpened ||
|
||||
(isSidePanelOpened && hasCommandMenuPageInNavigationStack);
|
||||
|
||||
if (shouldHideButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ariaLabel = t`Command Menu`;
|
||||
|
||||
const { theme } = useContext(ThemeContext);
|
||||
return (
|
||||
<StyledButtonWrapper alignToTop={alignWithSidePanelTopBar}>
|
||||
<div id="toggle-side-panel-button">
|
||||
<AnimatedButton
|
||||
animatedSvg={<AnimatedIcon isSidePanelOpened={showAsOpen} />}
|
||||
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
|
||||
<div
|
||||
id="toggle-side-panel-button"
|
||||
data-click-outside-id={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<IconButton
|
||||
Icon={IconDotsVertical}
|
||||
dataTestId="page-header-side-panel-button"
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
square
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
ariaLabel={ariaLabel}
|
||||
onClick={toggleSidePanelMenu}
|
||||
animate={{
|
||||
rotate: showAsOpen ? 90 : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.normal,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
onClick={openSidePanelMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SidePanelBackButton } from '@/side-panel/components/SidePanelBackButton
|
||||
import { SidePanelPageInfo } from '@/side-panel/components/SidePanelPageInfo';
|
||||
import { SidePanelTopBarInputFocusEffect } from '@/side-panel/components/SidePanelTopBarInputFocusEffect';
|
||||
import { SidePanelTopBarRightCornerIcon } from '@/side-panel/components/SidePanelTopBarRightCornerIcon';
|
||||
import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages';
|
||||
import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBarHeight';
|
||||
import { SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE } from '@/side-panel/constants/SidePanelTopBarHeightMobile';
|
||||
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||
@@ -19,18 +20,11 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useContext, useRef } from 'react';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { IconX } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const COMMAND_MENU_SIDE_PANEL_PAGES = [
|
||||
SidePanelPages.CommandMenuDisplay,
|
||||
SidePanelPages.CommandMenuEdit,
|
||||
SidePanelPages.SearchRecords,
|
||||
];
|
||||
|
||||
const StyledInputContainer = styled.div<{ isMobile: boolean }>`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
@@ -46,7 +40,6 @@ const StyledInputContainer = styled.div<{ isMobile: boolean }>`
|
||||
height: ${({ isMobile }) =>
|
||||
isMobile ? SIDE_PANEL_TOP_BAR_HEIGHT_MOBILE : SIDE_PANEL_TOP_BAR_HEIGHT}px;
|
||||
justify-content: space-between;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
|
||||
outline: none;
|
||||
@@ -82,6 +75,13 @@ const StyledContentContainer = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledRightControlsContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
export const SidePanelTopBar = () => {
|
||||
const [sidePanelSearch, setSidePanelSearch] =
|
||||
useAtomState(sidePanelSearchState);
|
||||
@@ -133,9 +133,6 @@ export const SidePanelTopBar = () => {
|
||||
const currentPage = sidePanelNavigationStack.at(-1)?.page;
|
||||
const previousPage = sidePanelNavigationStack.at(-2)?.page;
|
||||
|
||||
// A command-menu page is the root of a fresh command-menu session, so it only
|
||||
// offers a back chevron when it was opened from another command-menu page.
|
||||
// Every other side-panel page keeps standard "go back when there is history".
|
||||
const canGoBack =
|
||||
currentPage !== undefined &&
|
||||
COMMAND_MENU_SIDE_PANEL_PAGES.includes(currentPage)
|
||||
@@ -143,10 +140,10 @@ export const SidePanelTopBar = () => {
|
||||
COMMAND_MENU_SIDE_PANEL_PAGES.includes(previousPage)
|
||||
: sidePanelNavigationStack.length > 1;
|
||||
|
||||
const shouldShowCloseButton = !isMobile && !canGoBack;
|
||||
|
||||
const shouldShowBackButton = canGoBack;
|
||||
|
||||
const shouldShowCloseButton = !isMobile;
|
||||
|
||||
const lastChip = contextChips.at(-1);
|
||||
|
||||
return (
|
||||
@@ -155,6 +152,7 @@ export const SidePanelTopBar = () => {
|
||||
<AnimatePresence>
|
||||
{shouldShowBackButton && (
|
||||
<motion.div
|
||||
key="side-panel-back-button"
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration.instant,
|
||||
@@ -163,21 +161,6 @@ export const SidePanelTopBar = () => {
|
||||
<SidePanelBackButton />
|
||||
</motion.div>
|
||||
)}
|
||||
{shouldShowCloseButton && (
|
||||
<motion.div
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration.instant,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
Icon={IconX}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={closeSidePanelMenu}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{lastChip && !COMMAND_MENU_SIDE_PANEL_PAGES.includes(sidePanelPage) && (
|
||||
<SidePanelPageInfo pageChip={lastChip} />
|
||||
@@ -197,7 +180,18 @@ export const SidePanelTopBar = () => {
|
||||
</>
|
||||
)}
|
||||
</StyledContentContainer>
|
||||
<SidePanelTopBarRightCornerIcon />
|
||||
<StyledRightControlsContainer>
|
||||
<SidePanelTopBarRightCornerIcon />
|
||||
{shouldShowCloseButton && (
|
||||
<IconButton
|
||||
Icon={IconX}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={closeSidePanelMenu}
|
||||
ariaLabel={t`Close side panel`}
|
||||
/>
|
||||
)}
|
||||
</StyledRightControlsContainer>
|
||||
</StyledInputContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { sidePanelSearchObjectFilterState } from '@/side-panel/states/sidePanelSearchObjectFilterState';
|
||||
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
||||
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { IconDotsVertical } from 'twenty-ui/icon';
|
||||
|
||||
jest.mock('twenty-ui/utilities', () => ({
|
||||
useIsMobile: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('twenty-ui/surfaces', () => ({
|
||||
...jest.requireActual('twenty-ui/surfaces'),
|
||||
AppTooltip: () => null,
|
||||
}));
|
||||
|
||||
const renderSidePanelToggleButton = ({
|
||||
isSidePanelOpened = false,
|
||||
sidePanelPage = SidePanelPages.CommandMenuDisplay,
|
||||
sidePanelNavigationStack = [],
|
||||
sidePanelSearch = '',
|
||||
sidePanelSearchObjectFilter = null,
|
||||
}: {
|
||||
isSidePanelOpened?: boolean;
|
||||
sidePanelPage?: SidePanelPages;
|
||||
sidePanelNavigationStack?: Array<{
|
||||
page: SidePanelPages;
|
||||
pageTitle: string;
|
||||
pageIcon: typeof IconDotsVertical;
|
||||
pageId: string;
|
||||
}>;
|
||||
sidePanelSearch?: string;
|
||||
sidePanelSearchObjectFilter?: string | null;
|
||||
} = {}) => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(isSidePanelOpenedState.atom, isSidePanelOpened);
|
||||
store.set(sidePanelPageState.atom, sidePanelPage);
|
||||
store.set(sidePanelNavigationStackState.atom, sidePanelNavigationStack);
|
||||
store.set(sidePanelSearchState.atom, sidePanelSearch);
|
||||
store.set(sidePanelSearchObjectFilterState.atom, sidePanelSearchObjectFilter);
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<JotaiProvider store={store}>
|
||||
<MemoryRouter
|
||||
future={{
|
||||
v7_relativeSplatPath: true,
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
>
|
||||
<SidePanelToggleButton />
|
||||
</MemoryRouter>
|
||||
</JotaiProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
return { store };
|
||||
};
|
||||
|
||||
describe('SidePanelToggleButton', () => {
|
||||
it('opens the command menu when the side panel is closed', () => {
|
||||
const { store } = renderSidePanelToggleButton();
|
||||
|
||||
fireEvent.click(screen.getByTestId('page-header-side-panel-button'));
|
||||
|
||||
expect(store.get(isSidePanelOpenedState.atom)).toBe(true);
|
||||
expect(store.get(sidePanelPageState.atom)).toBe(
|
||||
SidePanelPages.CommandMenuDisplay,
|
||||
);
|
||||
expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hides the navbar command menu button while the command menu is open', () => {
|
||||
renderSidePanelToggleButton({
|
||||
isSidePanelOpened: true,
|
||||
sidePanelPage: SidePanelPages.CommandMenuDisplay,
|
||||
sidePanelNavigationStack: [
|
||||
{
|
||||
page: SidePanelPages.CommandMenuDisplay,
|
||||
pageTitle: 'Command Menu',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'command-menu',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('page-header-side-panel-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the navbar command menu button when the side panel has command-menu history', () => {
|
||||
renderSidePanelToggleButton({
|
||||
isSidePanelOpened: true,
|
||||
sidePanelPage: SidePanelPages.ViewRecord,
|
||||
sidePanelNavigationStack: [
|
||||
{
|
||||
page: SidePanelPages.CommandMenuDisplay,
|
||||
pageTitle: 'Command Menu',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'command-menu',
|
||||
},
|
||||
{
|
||||
page: SidePanelPages.ViewRecord,
|
||||
pageTitle: 'Company',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'view-record',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('page-header-side-panel-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the navbar command menu button on unrelated side-panel drill-down pages', () => {
|
||||
renderSidePanelToggleButton({
|
||||
isSidePanelOpened: true,
|
||||
sidePanelPage: SidePanelPages.WorkflowStepCreate,
|
||||
sidePanelNavigationStack: [
|
||||
{
|
||||
page: SidePanelPages.WorkflowStepEdit,
|
||||
pageTitle: 'Edit step',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'workflow-step-edit',
|
||||
},
|
||||
{
|
||||
page: SidePanelPages.WorkflowStepCreate,
|
||||
pageTitle: 'Create step',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'workflow-step-create',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('page-header-side-panel-button')).toBeVisible();
|
||||
});
|
||||
|
||||
it('marks the command menu button as a click-outside exclusion', () => {
|
||||
renderSidePanelToggleButton();
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('page-header-side-panel-button')
|
||||
.closest('[data-click-outside-id]'),
|
||||
).toHaveAttribute(
|
||||
'data-click-outside-id',
|
||||
PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a directly opened side-panel page with the root command menu', () => {
|
||||
const { store } = renderSidePanelToggleButton({
|
||||
isSidePanelOpened: true,
|
||||
sidePanelPage: SidePanelPages.ViewRecord,
|
||||
sidePanelSearch: 'acme',
|
||||
sidePanelSearchObjectFilter: 'company',
|
||||
sidePanelNavigationStack: [
|
||||
{
|
||||
page: SidePanelPages.ViewRecord,
|
||||
pageTitle: 'Company',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'view-record',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('page-header-side-panel-button'));
|
||||
|
||||
expect(store.get(isSidePanelOpenedState.atom)).toBe(true);
|
||||
expect(store.get(sidePanelPageState.atom)).toBe(
|
||||
SidePanelPages.CommandMenuDisplay,
|
||||
);
|
||||
expect(store.get(sidePanelNavigationStackState.atom)).toMatchObject([
|
||||
{
|
||||
page: SidePanelPages.CommandMenuDisplay,
|
||||
pageTitle: 'Command Menu',
|
||||
},
|
||||
]);
|
||||
expect(store.get(sidePanelSearchState.atom)).toBe('');
|
||||
expect(store.get(sidePanelSearchObjectFilterState.atom)).toBeNull();
|
||||
});
|
||||
});
|
||||
+113
-11
@@ -36,8 +36,10 @@ jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockIsMobile = false;
|
||||
|
||||
jest.mock('twenty-ui/utilities', () => ({
|
||||
useIsMobile: () => true,
|
||||
useIsMobile: () => mockIsMobile,
|
||||
}));
|
||||
|
||||
const recordIndexFocusItem = {
|
||||
@@ -52,27 +54,36 @@ const recordIndexFocusItem = {
|
||||
},
|
||||
};
|
||||
|
||||
const createSidePanelTopBarStore = () => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(isSidePanelOpenedState.atom, true);
|
||||
store.set(sidePanelPageState.atom, SidePanelPages.CommandMenuDisplay);
|
||||
store.set(sidePanelNavigationStackState.atom, [
|
||||
const createSidePanelTopBarStore = ({
|
||||
sidePanelPage = SidePanelPages.CommandMenuDisplay,
|
||||
sidePanelNavigationStack = [
|
||||
{
|
||||
page: SidePanelPages.CommandMenuDisplay,
|
||||
pageTitle: 'Command Menu',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'command-menu',
|
||||
},
|
||||
]);
|
||||
],
|
||||
}: {
|
||||
sidePanelPage?: SidePanelPages;
|
||||
sidePanelNavigationStack?: Array<{
|
||||
page: SidePanelPages;
|
||||
pageTitle: string;
|
||||
pageIcon: typeof IconDotsVertical;
|
||||
pageId: string;
|
||||
}>;
|
||||
} = {}) => {
|
||||
const store = createStore();
|
||||
|
||||
store.set(isSidePanelOpenedState.atom, true);
|
||||
store.set(sidePanelPageState.atom, sidePanelPage);
|
||||
store.set(sidePanelNavigationStackState.atom, sidePanelNavigationStack);
|
||||
store.set(focusStackState.atom, [recordIndexFocusItem]);
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
const renderSidePanelCommandMenu = () => {
|
||||
const store = createSidePanelTopBarStore();
|
||||
|
||||
const renderSidePanelCommandMenu = (store = createSidePanelTopBarStore()) => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<JotaiProvider store={store}>
|
||||
@@ -93,6 +104,10 @@ const renderSidePanelCommandMenu = () => {
|
||||
};
|
||||
|
||||
describe('SidePanelTopBar', () => {
|
||||
beforeEach(() => {
|
||||
mockIsMobile = false;
|
||||
});
|
||||
|
||||
it('keeps the command menu search input focused while arrowing through items', async () => {
|
||||
const { store } = renderSidePanelCommandMenu();
|
||||
|
||||
@@ -134,4 +149,91 @@ describe('SidePanelTopBar', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the close button on the root command menu', () => {
|
||||
renderSidePanelCommandMenu();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Close side panel' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Back' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the close button on mobile', () => {
|
||||
mockIsMobile = true;
|
||||
|
||||
renderSidePanelCommandMenu();
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Close side panel' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the close button after the command menu content', () => {
|
||||
renderSidePanelCommandMenu();
|
||||
|
||||
const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
|
||||
const closeButton = screen.getByRole('button', {
|
||||
name: 'Close side panel',
|
||||
});
|
||||
|
||||
expect(
|
||||
Boolean(
|
||||
input.compareDocumentPosition(closeButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows both back and close buttons for command menu subpages', () => {
|
||||
renderSidePanelCommandMenu(
|
||||
createSidePanelTopBarStore({
|
||||
sidePanelPage: SidePanelPages.SearchRecords,
|
||||
sidePanelNavigationStack: [
|
||||
{
|
||||
page: SidePanelPages.CommandMenuDisplay,
|
||||
pageTitle: 'Command Menu',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'command-menu',
|
||||
},
|
||||
{
|
||||
page: SidePanelPages.SearchRecords,
|
||||
pageTitle: 'Search',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'search-records',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Close side panel' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only the close button when a page was opened directly', () => {
|
||||
renderSidePanelCommandMenu(
|
||||
createSidePanelTopBarStore({
|
||||
sidePanelPage: SidePanelPages.ViewRecord,
|
||||
sidePanelNavigationStack: [
|
||||
{
|
||||
page: SidePanelPages.ViewRecord,
|
||||
pageTitle: 'Company',
|
||||
pageIcon: IconDotsVertical,
|
||||
pageId: 'view-record',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Close side panel' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Back' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
|
||||
export const COMMAND_MENU_SIDE_PANEL_PAGES = [
|
||||
SidePanelPages.CommandMenuDisplay,
|
||||
SidePanelPages.CommandMenuEdit,
|
||||
SidePanelPages.SearchRecords,
|
||||
];
|
||||
@@ -60,6 +60,9 @@ export const useSidePanelMenu = () => {
|
||||
const openSidePanelMenu = useCallback(() => {
|
||||
emitSidePanelOpenEvent();
|
||||
closeAnyOpenDropdown();
|
||||
store.set(sidePanelSearchState.atom, '');
|
||||
store.set(sidePanelSearchObjectFilterState.atom, null);
|
||||
|
||||
const isLayoutCustomizationModeEnabled = store.get(
|
||||
isLayoutCustomizationModeEnabledState.atom,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user