diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 78d128fd3..9e91ce7c9 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -13,6 +13,7 @@ import { setSessionId, useEventTracker, } from '@/analytics/hooks/useEventTracker'; +import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange'; import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCaptchaToken'; import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; @@ -52,13 +53,17 @@ export const PageChangeEffect = () => { const resetTableSelections = useResetTableRowSelection(objectNamePlural); + const { executeTasksOnAnyLocationChange } = + useExecuteTasksOnAnyLocationChange(); + useEffect(() => { if (!previousLocation || previousLocation !== location.pathname) { setPreviousLocation(location.pathname); + executeTasksOnAnyLocationChange(); } else { return; } - }, [location, previousLocation]); + }, [location, previousLocation, executeTasksOnAnyLocationChange]); const [searchParams] = useSearchParams(); diff --git a/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts b/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts new file mode 100644 index 000000000..a4f1e54d3 --- /dev/null +++ b/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts @@ -0,0 +1,16 @@ +import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown'; + +export const useExecuteTasksOnAnyLocationChange = () => { + const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown(); + + /** + * Be careful to put idempotent tasks here. + * + * Because it might be called multiple times. + */ + const executeTasksOnAnyLocationChange = () => { + closeAnyOpenDropdown(); + }; + + return { executeTasksOnAnyLocationChange }; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx new file mode 100644 index 000000000..f51add78e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx @@ -0,0 +1,44 @@ +import { expect } from '@storybook/test'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; + +const dropdownId = 'test-dropdown-id'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +describe('useCloseAnyOpenDropdown', () => { + it('should open dropdown and then close it with closeAnyOpenDropdown', async () => { + const { result } = renderHook( + () => { + const { openDropdown, isDropdownOpen } = useDropdown(dropdownId); + + const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown(); + + return { closeAnyOpenDropdown, isDropdownOpen, openDropdown }; + }, + { + wrapper: Wrapper, + }, + ); + + expect(result.current.isDropdownOpen).toBe(false); + + act(() => { + result.current.openDropdown(); + }); + + expect(result.current.isDropdownOpen).toBe(true); + + act(() => { + result.current.closeAnyOpenDropdown(); + }); + + expect(result.current.isDropdownOpen).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseDropdownFromOutside.test.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseDropdownFromOutside.test.tsx new file mode 100644 index 000000000..01c5c5eb4 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseDropdownFromOutside.test.tsx @@ -0,0 +1,43 @@ +import { expect } from '@storybook/test'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useCloseDropdownFromOutside } from '@/ui/layout/dropdown/hooks/useCloseDropdownFromOutside'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; + +const dropdownId = 'test-dropdown-id'; + +const Wrapper = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +describe('useCloseDropdownFromOutside', () => { + it('should close open dropdown', async () => { + const { result } = renderHook( + () => { + const { isDropdownOpen, openDropdown } = useDropdown(dropdownId); + const { closeDropdownFromOutside } = useCloseDropdownFromOutside(); + + return { closeDropdownFromOutside, isDropdownOpen, openDropdown }; + }, + { + wrapper: Wrapper, + }, + ); + + expect(result.current.isDropdownOpen).toBe(false); + + act(() => { + result.current.openDropdown(); + }); + + expect(result.current.isDropdownOpen).toBe(true); + + act(() => { + result.current.closeDropdownFromOutside(dropdownId); + }); + + expect(result.current.isDropdownOpen).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts new file mode 100644 index 000000000..efe0660f8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts @@ -0,0 +1,51 @@ +import { useCloseDropdownFromOutside } from '@/ui/layout/dropdown/hooks/useCloseDropdownFromOutside'; +import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; +import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useCloseAnyOpenDropdown = () => { + const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope(); + + const { closeDropdownFromOutside } = useCloseDropdownFromOutside(); + + const closeAnyOpenDropdown = useRecoilCallback( + ({ snapshot, set }) => + () => { + const previousDropdownFocusId = snapshot + .getLoadable(previousDropdownFocusIdState) + .getValue(); + + const activeDropdownFocusId = snapshot + .getLoadable(activeDropdownFocusIdState) + .getValue(); + + const thereIsNoDropdownOpen = + !isDefined(activeDropdownFocusId) && + !isDefined(previousDropdownFocusId); + + if (thereIsNoDropdownOpen) { + return; + } + + const thereIsOneNestedDropdownOpen = isDefined(previousDropdownFocusId); + + if (isDefined(activeDropdownFocusId)) { + closeDropdownFromOutside(activeDropdownFocusId); + } + + if (thereIsOneNestedDropdownOpen) { + closeDropdownFromOutside(previousDropdownFocusId); + } + + set(previousDropdownFocusIdState, null); + set(activeDropdownFocusIdState, null); + + goBackToPreviousHotkeyScope(); + }, + [closeDropdownFromOutside, goBackToPreviousHotkeyScope], + ); + + return { closeAnyOpenDropdown }; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseDropdownFromOutside.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseDropdownFromOutside.ts new file mode 100644 index 000000000..9deb63ee6 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseDropdownFromOutside.ts @@ -0,0 +1,14 @@ +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { useRecoilCallback } from 'recoil'; + +export const useCloseDropdownFromOutside = () => { + const closeDropdownFromOutside = useRecoilCallback( + ({ set }) => + (dropdownId: string) => { + set(isDropdownOpenComponentState({ scopeId: dropdownId }), false); + }, + [], + ); + + return { closeDropdownFromOutside }; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts index 5c336ac6c..157aaf5ad 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts @@ -3,6 +3,7 @@ import { useRecoilCallback } from 'recoil'; import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState'; +// TODO: this won't work for more than 1 nested dropdown export const useGoBackToPreviousDropdownFocusId = () => { const goBackToPreviousDropdownFocusId = useRecoilCallback( ({ snapshot, set }) => diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts index 95b923916..2a3bf9dc8 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts @@ -7,10 +7,6 @@ export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => { const setActiveDropdownFocusIdAndMemorizePrevious = useRecoilCallback( ({ snapshot, set }) => (dropdownId: string | null) => { - const focusedDropdownId = snapshot - .getLoadable(activeDropdownFocusIdState) - .getValue(); - const activeDropdownFocusId = snapshot .getLoadable(activeDropdownFocusIdState) .getValue(); @@ -19,7 +15,7 @@ export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => { return; } - set(previousDropdownFocusIdState, focusedDropdownId); + set(previousDropdownFocusIdState, activeDropdownFocusId); set(activeDropdownFocusIdState, dropdownId); }, [],