Continue the workspace setup chat in the side panel when navigating away (#23744)
https://github.com/user-attachments/assets/579a8d41-901e-41c0-85a4-f23e6bc3da8d The /workspace-setup full-page chat shows the nav drawer, so users can navigate away mid-conversation and lose sight of the chat. Leaving the page by any means (drawer link, browser back) now opens the same conversation in the Ask AI side panel, with the full-page chat visually shrinking into the panel via the panel's existing width transition. The page marks a handoff atom while mounted; the side panel consumes it in a mount layout effect (pre-paint, so no flash frame), opens the Ask AI page, and enters at full width before shrinking. The Close button still exits without reopening the panel, the Collapse button keeps its behavior and gains the same animation, and prefers-reduced-motion skips it. Mobile is unchanged since the full-screen panel would cover the destination page. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23744?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:
+95
@@ -0,0 +1,95 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { useReturnFromExpandedAiChat } from '@/ai/hooks/useReturnFromExpandedAiChat';
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const navigateMock = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
const defaultHomePagePath = '/objects/companies';
|
||||
|
||||
jest.mock('@/navigation/hooks/useDefaultHomePagePath', () => ({
|
||||
useDefaultHomePagePath: () => ({ defaultHomePagePath }),
|
||||
}));
|
||||
|
||||
const openAskAiPageMock = jest.fn();
|
||||
|
||||
jest.mock('@/side-panel/hooks/useOpenAskAiPageInSidePanel', () => ({
|
||||
useOpenAskAiPageInSidePanel: () => ({ openAskAiPage: openAskAiPageMock }),
|
||||
}));
|
||||
|
||||
const closeSidePanelMenuMock = jest.fn();
|
||||
|
||||
jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
|
||||
useSidePanelMenu: () => ({ closeSidePanelMenu: closeSidePanelMenuMock }),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('useReturnFromExpandedAiChat', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
resetJotaiStore();
|
||||
});
|
||||
|
||||
it('should reopen the side panel and keep the side panel continuation when collapsing', () => {
|
||||
jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
|
||||
jotaiStore.set(aiChatExpandedReturnLocationState.atom, '/objects/people');
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useReturnFromExpandedAiChat({ reopenSidePanel: true }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(openAskAiPageMock).toHaveBeenCalledWith({
|
||||
resetNavigationStack: true,
|
||||
});
|
||||
expect(closeSidePanelMenuMock).not.toHaveBeenCalled();
|
||||
expect(navigateMock).toHaveBeenCalledWith('/objects/people');
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(jotaiStore.get(aiChatExpandedReturnLocationState.atom)).toBeNull();
|
||||
expect(jotaiStore.get(shouldOpenAiChatAfterOnboardingState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should cancel the side panel continuation when closing', () => {
|
||||
jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useReturnFromExpandedAiChat({ reopenSidePanel: false }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(openAskAiPageMock).not.toHaveBeenCalled();
|
||||
expect(closeSidePanelMenuMock).toHaveBeenCalled();
|
||||
expect(navigateMock).toHaveBeenCalledWith(defaultHomePagePath);
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
|
||||
@@ -25,6 +26,7 @@ export const useReturnFromExpandedAiChat = ({
|
||||
if (reopenSidePanel) {
|
||||
openAskAiPage({ resetNavigationStack: true });
|
||||
} else {
|
||||
store.set(shouldContinueAiChatInSidePanelState.atom, false);
|
||||
void closeSidePanelMenu();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const shouldContinueAiChatInSidePanelState = createAtomState<boolean>({
|
||||
key: 'shouldContinueAiChatInSidePanelState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const WorkspaceSetupChatSidePanelHandoffEffect = () => {
|
||||
const setShouldContinueAiChatInSidePanel = useSetAtomState(
|
||||
shouldContinueAiChatInSidePanelState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setShouldContinueAiChatInSidePanel(true);
|
||||
|
||||
return () => {
|
||||
setShouldContinueAiChatInSidePanel(false);
|
||||
};
|
||||
}, [setShouldContinueAiChatInSidePanel]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { useStore } from 'jotai';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
|
||||
|
||||
export const SidePanelAskAiHandoffEffect = () => {
|
||||
const store = useStore();
|
||||
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!store.get(shouldContinueAiChatInSidePanelState.atom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(shouldContinueAiChatInSidePanelState.atom, false);
|
||||
store.set(shouldOpenAiChatAfterOnboardingState.atom, false);
|
||||
store.set(aiChatExpandedReturnLocationState.atom, null);
|
||||
|
||||
openAskAiPage({ resetNavigationStack: true });
|
||||
}, [store, openAskAiPage]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
|
||||
import { SidePanelAskAiHandoffEffect } from '@/side-panel/components/SidePanelAskAiHandoffEffect';
|
||||
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
|
||||
import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect';
|
||||
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
|
||||
import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints';
|
||||
import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth';
|
||||
import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
|
||||
@@ -18,7 +20,8 @@ import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { type AnimationEvent, useCallback, useState } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledSidePanelWrapper = styled.div<{
|
||||
@@ -33,9 +36,20 @@ const StyledSidePanelWrapper = styled.div<{
|
||||
? 'none'
|
||||
: `width calc(${themeCssVariables.animation.duration.normal} * 1s)`};
|
||||
width: ${({ isOpen }) => (isOpen ? `var(${SIDE_PANEL_WIDTH_VAR})` : '0px')};
|
||||
|
||||
@keyframes sidePanelShrinkFromFullWidth {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-shrink-from-full-width='true'] {
|
||||
animation: sidePanelShrinkFromFullWidth
|
||||
calc(${themeCssVariables.animation.duration.normal} * 1s);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSidePanel = styled.aside`
|
||||
const StyledSidePanel = styled.aside<{ isShrinkingFromFullWidth: boolean }>`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-left: 1px solid ${themeCssVariables.border.color.medium};
|
||||
box-sizing: border-box;
|
||||
@@ -44,7 +58,8 @@ const StyledSidePanel = styled.aside`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: var(${SIDE_PANEL_WIDTH_VAR});
|
||||
width: ${({ isShrinkingFromFullWidth }) =>
|
||||
isShrinkingFromFullWidth ? '100%' : `var(${SIDE_PANEL_WIDTH_VAR})`};
|
||||
`;
|
||||
|
||||
const StyledModalContainer = styled.div`
|
||||
@@ -58,12 +73,13 @@ const StyledModalContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const SidePanelForDesktop = () => {
|
||||
const store = useStore();
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
const isSidePanelClosing = useAtomStateValue(isSidePanelClosingState);
|
||||
const [sidePanelWidth, setSidePanelWidth] = useAtomState(sidePanelWidthState);
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
const { sidePanelCloseAnimationCompleteCleanup } =
|
||||
useSidePanelCloseAnimationCompleteCleanup();
|
||||
const shouldShrinkFromFullWidth = useShouldShrinkSidePanelFromFullWidth();
|
||||
|
||||
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
@@ -71,6 +87,9 @@ export const SidePanelForDesktop = () => {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [shouldRenderContent, setShouldRenderContent] =
|
||||
useState(isSidePanelOpened);
|
||||
const [isShrinkingFromFullWidth, setIsShrinkingFromFullWidth] = useState(
|
||||
shouldShrinkFromFullWidth,
|
||||
);
|
||||
|
||||
const setTableWidthResizeIsActive = useSetAtomState(
|
||||
tableWidthResizeIsActiveState,
|
||||
@@ -78,17 +97,29 @@ export const SidePanelForDesktop = () => {
|
||||
|
||||
const shouldShowContent = isSidePanelOpened || shouldRenderContent;
|
||||
|
||||
if (isSidePanelOpened && !shouldRenderContent) {
|
||||
setShouldRenderContent(true);
|
||||
}
|
||||
|
||||
const handleTransitionEnd = () => {
|
||||
if (isSidePanelOpened) {
|
||||
// Open animation completed - ensure content persists for close animation
|
||||
setShouldRenderContent(true);
|
||||
} else {
|
||||
// Close animation completed
|
||||
setShouldRenderContent(false);
|
||||
if (isSidePanelClosing) {
|
||||
sidePanelCloseAnimationCompleteCleanup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setShouldRenderContent(false);
|
||||
|
||||
if (store.get(isSidePanelClosingState.atom)) {
|
||||
sidePanelCloseAnimationCompleteCleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimationEnd = (event: AnimationEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsShrinkingFromFullWidth(false);
|
||||
handleTransitionEnd();
|
||||
};
|
||||
|
||||
const handleModalContainerRef = useCallback(
|
||||
@@ -121,6 +152,7 @@ export const SidePanelForDesktop = () => {
|
||||
return (
|
||||
<>
|
||||
<SidePanelWidthEffect />
|
||||
<SidePanelAskAiHandoffEffect />
|
||||
<ResizablePanelGap
|
||||
side="left"
|
||||
constraints={SIDE_PANEL_CONSTRAINTS}
|
||||
@@ -136,10 +168,12 @@ export const SidePanelForDesktop = () => {
|
||||
isOpen={isSidePanelOpened}
|
||||
isResizing={isResizing}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
data-shrink-from-full-width={isShrinkingFromFullWidth}
|
||||
data-side-panel=""
|
||||
data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<StyledSidePanel>
|
||||
<StyledSidePanel isShrinkingFromFullWidth={isShrinkingFromFullWidth}>
|
||||
<StyledModalContainer ref={handleModalContainerRef} />
|
||||
<ModalContainerContext.Provider value={{ container: modalContainer }}>
|
||||
<ParentClickOutsideIdContext.Provider
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { WorkspaceSetupChatSidePanelHandoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { SidePanelAskAiHandoffEffect } from '@/side-panel/components/SidePanelAskAiHandoffEffect';
|
||||
import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const openAskAiPageMock = jest.fn();
|
||||
|
||||
jest.mock('@/side-panel/hooks/useOpenAskAiPageInSidePanel', () => ({
|
||||
useOpenAskAiPageInSidePanel: () => ({ openAskAiPage: openAskAiPageMock }),
|
||||
}));
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
useReducedMotion: () => false,
|
||||
}));
|
||||
|
||||
let navigateAwayFromWorkspaceSetup: (() => void) | undefined;
|
||||
|
||||
const WorkspaceSetupRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
navigateAwayFromWorkspaceSetup = () => navigate('/objects/companies');
|
||||
|
||||
return <WorkspaceSetupChatSidePanelHandoffEffect />;
|
||||
};
|
||||
|
||||
const SidePanelRoute = () => {
|
||||
const shouldShrinkFromFullWidth = useShouldShrinkSidePanelFromFullWidth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidePanelAskAiHandoffEffect />
|
||||
<div data-testid="side-panel">{String(shouldShrinkFromFullWidth)}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RouterUnderTest = () => (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<MemoryRouter initialEntries={['/workspace-setup']}>
|
||||
<Routes>
|
||||
<Route path="/workspace-setup" element={<WorkspaceSetupRoute />} />
|
||||
<Route path="/objects/companies" element={<SidePanelRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('SidePanelAskAiHandoffEffect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
resetJotaiStore();
|
||||
navigateAwayFromWorkspaceSetup = undefined;
|
||||
});
|
||||
|
||||
it('should consume the marker and open the ask ai page when the workspace setup page unmounts in the same commit', () => {
|
||||
jotaiStore.set(shouldOpenAiChatAfterOnboardingState.atom, true);
|
||||
jotaiStore.set(aiChatExpandedReturnLocationState.atom, '/objects/people');
|
||||
|
||||
const { getByTestId } = render(<RouterUnderTest />);
|
||||
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
navigateAwayFromWorkspaceSetup?.();
|
||||
});
|
||||
|
||||
expect(openAskAiPageMock).toHaveBeenCalledWith({
|
||||
resetNavigationStack: true,
|
||||
});
|
||||
expect(getByTestId('side-panel')).toHaveTextContent('true');
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(jotaiStore.get(shouldOpenAiChatAfterOnboardingState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(jotaiStore.get(aiChatExpandedReturnLocationState.atom)).toBeNull();
|
||||
});
|
||||
|
||||
it('should do nothing when the marker is not set', () => {
|
||||
render(
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<SidePanelAskAiHandoffEffect />
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
expect(openAskAiPageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
|
||||
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const shouldShrinkFromFullWidthMock = jest.fn();
|
||||
const sidePanelCloseAnimationCompleteCleanupMock = jest.fn();
|
||||
|
||||
jest.mock('@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth', () => ({
|
||||
useShouldShrinkSidePanelFromFullWidth: () => shouldShrinkFromFullWidthMock(),
|
||||
}));
|
||||
|
||||
jest.mock('@/side-panel/components/SidePanelAskAiHandoffEffect', () => ({
|
||||
SidePanelAskAiHandoffEffect: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@/side-panel/components/SidePanelRouter', () => ({
|
||||
SidePanelRouter: () => <div data-testid="side-panel-content" />,
|
||||
}));
|
||||
|
||||
jest.mock('@/side-panel/components/SidePanelWidthEffect', () => ({
|
||||
SidePanelWidthEffect: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/layout/resizable-panel/components/ResizablePanelGap', () => ({
|
||||
ResizablePanelGap: () => null,
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup',
|
||||
() => ({
|
||||
useSidePanelCloseAnimationCompleteCleanup: () => ({
|
||||
sidePanelCloseAnimationCompleteCleanup:
|
||||
sidePanelCloseAnimationCompleteCleanupMock,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
|
||||
useSidePanelMenu: () => ({ closeSidePanelMenu: jest.fn() }),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('SidePanelForDesktop', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetJotaiStore();
|
||||
shouldShrinkFromFullWidthMock.mockReturnValue(false);
|
||||
sidePanelCloseAnimationCompleteCleanupMock.mockImplementation(() => {
|
||||
jotaiStore.set(isSidePanelClosingState.atom, false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the content mounted while closing after a handoff entrance', () => {
|
||||
shouldShrinkFromFullWidthMock.mockReturnValue(true);
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, true);
|
||||
|
||||
const { queryByTestId } = render(<SidePanelForDesktop />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(queryByTestId('side-panel-content')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, false);
|
||||
});
|
||||
|
||||
expect(queryByTestId('side-panel-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should stop shrinking from full width once the entrance animation ends', () => {
|
||||
shouldShrinkFromFullWidthMock.mockReturnValue(true);
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, true);
|
||||
|
||||
const { container } = render(<SidePanelForDesktop />, { wrapper: Wrapper });
|
||||
|
||||
const wrapperElement = container.querySelector('[data-side-panel]');
|
||||
|
||||
if (wrapperElement === null) {
|
||||
throw new Error('side panel wrapper not found');
|
||||
}
|
||||
|
||||
expect(wrapperElement).toHaveAttribute(
|
||||
'data-shrink-from-full-width',
|
||||
'true',
|
||||
);
|
||||
|
||||
fireEvent.animationEnd(wrapperElement);
|
||||
|
||||
expect(wrapperElement).toHaveAttribute(
|
||||
'data-shrink-from-full-width',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('should complete the close lifecycle when closed while the entrance animation is still running', () => {
|
||||
shouldShrinkFromFullWidthMock.mockReturnValue(true);
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, true);
|
||||
|
||||
const { container, queryByTestId } = render(<SidePanelForDesktop />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const wrapperElement = container.querySelector('[data-side-panel]');
|
||||
|
||||
if (wrapperElement === null) {
|
||||
throw new Error('side panel wrapper not found');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, false);
|
||||
jotaiStore.set(isSidePanelClosingState.atom, true);
|
||||
});
|
||||
|
||||
fireEvent.animationEnd(wrapperElement);
|
||||
|
||||
expect(sidePanelCloseAnimationCompleteCleanupMock).toHaveBeenCalled();
|
||||
expect(queryByTestId('side-panel-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should run the close cleanup once when both the animation and the transition end', () => {
|
||||
shouldShrinkFromFullWidthMock.mockReturnValue(true);
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, true);
|
||||
|
||||
const { container } = render(<SidePanelForDesktop />, { wrapper: Wrapper });
|
||||
|
||||
const wrapperElement = container.querySelector('[data-side-panel]');
|
||||
|
||||
if (wrapperElement === null) {
|
||||
throw new Error('side panel wrapper not found');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, false);
|
||||
jotaiStore.set(isSidePanelClosingState.atom, true);
|
||||
});
|
||||
|
||||
fireEvent.animationEnd(wrapperElement);
|
||||
fireEvent.transitionEnd(wrapperElement);
|
||||
|
||||
expect(sidePanelCloseAnimationCompleteCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not shrink from full width on a normal open', () => {
|
||||
jotaiStore.set(isSidePanelOpenedState.atom, true);
|
||||
|
||||
const { container } = render(<SidePanelForDesktop />, { wrapper: Wrapper });
|
||||
|
||||
expect(container.querySelector('[data-side-panel]')).toHaveAttribute(
|
||||
'data-shrink-from-full-width',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const useReducedMotionMock = jest.fn();
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
useReducedMotion: () => useReducedMotionMock(),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('useShouldShrinkSidePanelFromFullWidth', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetJotaiStore();
|
||||
useReducedMotionMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should shrink from full width when arriving from the workspace setup page', () => {
|
||||
jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useShouldShrinkSidePanelFromFullWidth(),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the marker untouched so the handoff effect can consume it', () => {
|
||||
jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
|
||||
|
||||
renderHook(() => useShouldShrinkSidePanelFromFullWidth(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not shrink from full width when not arriving from the workspace setup page', () => {
|
||||
const { result } = renderHook(
|
||||
() => useShouldShrinkSidePanelFromFullWidth(),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should not shrink from full width when reduced motion is preferred', () => {
|
||||
useReducedMotionMock.mockReturnValue(true);
|
||||
jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useShouldShrinkSidePanelFromFullWidth(),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { useReducedMotion } from 'framer-motion';
|
||||
import { useStore } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
|
||||
export const useShouldShrinkSidePanelFromFullWidth = () => {
|
||||
const store = useStore();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const [isContinuingChatFromWorkspaceSetup] = useState(() =>
|
||||
store.get(shouldContinueAiChatInSidePanelState.atom),
|
||||
);
|
||||
|
||||
return isContinuingChatFromWorkspaceSetup && !shouldReduceMotion;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
|
||||
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
|
||||
import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader';
|
||||
import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect';
|
||||
import { WorkspaceSetupChatSidePanelHandoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
@@ -59,6 +60,7 @@ export const WorkspaceSetup = () => {
|
||||
<StyledPanel>
|
||||
<WorkspaceSetupHeader title={title} />
|
||||
<StyledContent>
|
||||
<WorkspaceSetupChatSidePanelHandoffEffect />
|
||||
{shouldOpenAiChatAfterOnboarding && <WorkspaceSetupChatKickoffEffect />}
|
||||
<AiChatMessageListPreambleContext.Provider value={preamble}>
|
||||
<AiChatTab />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
|
||||
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import {
|
||||
@@ -121,6 +122,32 @@ describe('WorkspaceSetup', () => {
|
||||
expect(queryByTestId('chat-kickoff-effect')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should mark the chat for side panel continuation while mounted', () => {
|
||||
setIsOnboardingAiChatEnabled(true);
|
||||
|
||||
const { unmount } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not mark the chat for side panel continuation when the onboarding ai chat is disabled', () => {
|
||||
setIsOnboardingAiChatEnabled(false);
|
||||
|
||||
render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect home when the onboarding ai chat is disabled', () => {
|
||||
setIsOnboardingAiChatEnabled(false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user