Modal API Refactoring (#12062)
# Modal API Refactoring This PR refactors the modal system to use an imperative approach for setting hotkey scopes, addressing race conditions that occurred with the previous effect-based implementation. Fixes #11986 Closes #12087 ## Key Changes: - **New Modal API**: Introduced a `useModal` hook with `openModal`, `closeModal`, and `toggleModal` functions, similar to the existing dropdown API - **Modal Identification**: Added a `modalId` prop to uniquely identify modals - **State Management**: Introduced `isModalOpenedComponentState` and removed individual boolean state atoms (like `isRemoveSortingModalOpenState`) - **Modal Constants**: Added consistent modal ID constants (e.g., `FavoriteFolderDeleteModalId`, `RecordIndexRemoveSortingModalId`) for better maintainability - **Mount Effects**: Created mount effect components (like `AuthModalMountEffect`) to handle initial modal opening where needed ## Implementation Details: - Modified `Modal` and `ConfirmationModal` components to accept the new `modalId` prop - Added a component-state-based approach using `ModalComponentInstanceContext` to track modal state - Introduced imperative modal handlers that properly manage hotkey scopes - Components like `ActionModal` and `AttachmentList` now use the new `useModal` hook for better control over modal state ## Benefits: - **Race Condition Prevention**: Hotkey scopes are now set imperatively, eliminating race conditions - **Consistent API**: Modal and dropdown now share similar patterns, improving developer experience ## Tests to do before merging: 1. Action Modals (Modal triggered by an action, for example the delete action) 2. Auth Modal (`AuthModal.tsx` and `AuthModalMountEffect.tsx`) - Test that auth modal opens automatically on mount - Verify authentication flow works properly 3. Email Verification Sent Modal (in `SignInUp.tsx`) - Verify this modal displays correctly 4. Attachment Preview Modal (in `AttachmentList.tsx`) - Test opening preview modal by clicking on attachments - Verify close, download functionality works - Test modal navigation and interactions 5. Favorite Folder Delete Modal (`CurrentWorkspaceMemberFavorites.tsx`) - Test deletion confirmation flow - Check that modal opens when attempting to delete folders with favorites 6. Record Board Remove Sorting Modal (`RecordBoard.tsx` using `RecordIndexRemoveSortingModalId`) - Test that modal appears when trying to drag records with sorting enabled - Verify sorting removal works correctly 7. Record Group Reorder Confirmation Modal (`RecordGroupReorderConfirmationModal.tsx`) - Test group reordering with sorting enabled - Verify confirmation modal properly handles sorting removal 8. Confirmation Modal (base component used by several modals) - Test all variants with different confirmation options For each modal, verify: - Opening/closing behavior - Hotkey support (Esc to close, Enter to confirm where applicable) - Click outside behavior - Proper z-index stacking - Any modal-specific functionality
This commit is contained in:
@ -6,17 +6,18 @@ import { useDebouncedCallback } from 'use-debounce';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
|
||||
import { Modal, ModalVariants } from '@/ui/layout/modal/components/Modal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Button, ButtonAccent } from 'twenty-ui/input';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import { Button, ButtonAccent } from 'twenty-ui/input';
|
||||
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
|
||||
|
||||
export type ConfirmationModalProps = {
|
||||
isOpen: boolean;
|
||||
modalId: string;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
subtitle: ReactNode;
|
||||
setIsOpen: (val: boolean) => void;
|
||||
onClose?: () => void;
|
||||
onConfirmClick: () => void;
|
||||
confirmButtonText?: string;
|
||||
confirmationPlaceholder?: string;
|
||||
@ -58,12 +59,12 @@ export const StyledConfirmationButton = styled(StyledCenteredButton)`
|
||||
`;
|
||||
|
||||
export const ConfirmationModal = ({
|
||||
isOpen = false,
|
||||
modalId,
|
||||
title,
|
||||
loading,
|
||||
subtitle,
|
||||
setIsOpen,
|
||||
onConfirmClick,
|
||||
onClose,
|
||||
confirmButtonText = 'Confirm',
|
||||
confirmationValue,
|
||||
confirmationPlaceholder,
|
||||
@ -88,10 +89,16 @@ export const ConfirmationModal = ({
|
||||
250,
|
||||
);
|
||||
|
||||
const handleConfirmClick = () => {
|
||||
onConfirmClick();
|
||||
const { closeModal } = useModal();
|
||||
|
||||
setIsOpen(false);
|
||||
const handleConfirmClick = () => {
|
||||
closeModal(modalId);
|
||||
onConfirmClick();
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
closeModal(modalId);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleEnter = () => {
|
||||
@ -103,63 +110,59 @@ export const ConfirmationModal = ({
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<LayoutGroup>
|
||||
{isOpen && (
|
||||
<StyledConfirmationModal
|
||||
onClose={() => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onEnter={handleEnter}
|
||||
isClosable={true}
|
||||
padding="large"
|
||||
modalVariant={modalVariant}
|
||||
className="confirmation-modal"
|
||||
<StyledConfirmationModal
|
||||
modalId={modalId}
|
||||
onClose={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
onEnter={handleEnter}
|
||||
isClosable={true}
|
||||
padding="large"
|
||||
modalVariant={modalVariant}
|
||||
className="confirmation-modal"
|
||||
>
|
||||
<StyledCenteredTitle>
|
||||
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
|
||||
</StyledCenteredTitle>
|
||||
<StyledSection
|
||||
alignment={SectionAlignment.Center}
|
||||
fontColor={SectionFontColor.Primary}
|
||||
>
|
||||
<StyledCenteredTitle>
|
||||
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
|
||||
</StyledCenteredTitle>
|
||||
<StyledSection
|
||||
alignment={SectionAlignment.Center}
|
||||
fontColor={SectionFontColor.Primary}
|
||||
>
|
||||
{subtitle}
|
||||
</StyledSection>
|
||||
{confirmationValue && (
|
||||
<Section>
|
||||
<TextInput
|
||||
dataTestId="confirmation-modal-input"
|
||||
value={inputConfirmationValue}
|
||||
onChange={handleInputConfimrationValueChange}
|
||||
placeholder={confirmationPlaceholder}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
key={'input-' + confirmationValue}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<StyledCenteredButton
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
variant="secondary"
|
||||
title={t`Cancel`}
|
||||
fullWidth
|
||||
/>
|
||||
{subtitle}
|
||||
</StyledSection>
|
||||
{confirmationValue && (
|
||||
<Section>
|
||||
<TextInput
|
||||
dataTestId="confirmation-modal-input"
|
||||
value={inputConfirmationValue}
|
||||
onChange={handleInputConfimrationValueChange}
|
||||
placeholder={confirmationPlaceholder}
|
||||
fullWidth
|
||||
disableHotkeys
|
||||
key={'input-' + confirmationValue}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<StyledCenteredButton
|
||||
onClick={handleCancelClick}
|
||||
variant="secondary"
|
||||
title={t`Cancel`}
|
||||
fullWidth
|
||||
dataTestId="confirmation-modal-cancel-button"
|
||||
/>
|
||||
|
||||
{AdditionalButtons}
|
||||
{AdditionalButtons}
|
||||
|
||||
<StyledCenteredButton
|
||||
onClick={handleConfirmClick}
|
||||
variant="secondary"
|
||||
accent={confirmButtonAccent}
|
||||
title={confirmButtonText}
|
||||
disabled={!isValidValue || loading}
|
||||
fullWidth
|
||||
dataTestId="confirmation-modal-confirm-button"
|
||||
/>
|
||||
</StyledConfirmationModal>
|
||||
)}
|
||||
<StyledCenteredButton
|
||||
onClick={handleConfirmClick}
|
||||
variant="secondary"
|
||||
accent={confirmButtonAccent}
|
||||
title={confirmButtonText}
|
||||
disabled={!isValidValue || loading}
|
||||
fullWidth
|
||||
dataTestId="confirmation-modal-confirm-button"
|
||||
/>
|
||||
</StyledConfirmationModal>
|
||||
</LayoutGroup>
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect';
|
||||
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import {
|
||||
ClickOutsideMode,
|
||||
useListenClickOutside,
|
||||
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
|
||||
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
|
||||
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { css, useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
const StyledModalDiv = styled(motion.div)<{
|
||||
size?: ModalSize;
|
||||
padding?: ModalPadding;
|
||||
@ -169,6 +167,7 @@ export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
|
||||
export type ModalVariants = 'primary' | 'secondary' | 'tertiary';
|
||||
|
||||
export type ModalProps = React.PropsWithChildren & {
|
||||
modalId: string;
|
||||
size?: ModalSize;
|
||||
padding?: ModalPadding;
|
||||
className?: string;
|
||||
@ -176,7 +175,7 @@ export type ModalProps = React.PropsWithChildren & {
|
||||
onEnter?: () => void;
|
||||
modalVariant?: ModalVariants;
|
||||
} & (
|
||||
| { isClosable: true; onClose: () => void }
|
||||
| { isClosable: true; onClose?: () => void }
|
||||
| { isClosable?: false; onClose?: never }
|
||||
);
|
||||
|
||||
@ -187,11 +186,11 @@ const modalAnimation = {
|
||||
};
|
||||
|
||||
export const Modal = ({
|
||||
modalId,
|
||||
children,
|
||||
size = 'medium',
|
||||
padding = 'medium',
|
||||
className,
|
||||
hotkeyScope = ModalHotkeyScope.Default,
|
||||
onEnter,
|
||||
isClosable = false,
|
||||
onClose,
|
||||
@ -200,80 +199,65 @@ export const Modal = ({
|
||||
const isMobile = useIsMobile();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
goBackToPreviousHotkeyScope,
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
} = usePreviousHotkeyScope();
|
||||
|
||||
useEffect(() => {
|
||||
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
|
||||
return () => {
|
||||
goBackToPreviousHotkeyScope();
|
||||
};
|
||||
}, [
|
||||
hotkeyScope,
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
]);
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Enter],
|
||||
() => {
|
||||
onEnter?.();
|
||||
},
|
||||
hotkeyScope,
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape],
|
||||
() => {
|
||||
if (isClosable && onClose !== undefined) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
hotkeyScope,
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [modalRef],
|
||||
listenerId: 'MODAL_CLICK_OUTSIDE_LISTENER_ID',
|
||||
callback: () => {
|
||||
if (isClosable && onClose !== undefined) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
mode: ClickOutsideMode.comparePixels,
|
||||
});
|
||||
const theme = useTheme();
|
||||
|
||||
const stopEventPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
const isModalOpened = useRecoilComponentValueV2(
|
||||
isModalOpenedComponentState,
|
||||
modalId,
|
||||
);
|
||||
|
||||
const { closeModal } = useModal();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose?.();
|
||||
closeModal(modalId);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledBackDrop
|
||||
className="modal-backdrop"
|
||||
onMouseDown={stopEventPropagation}
|
||||
modalVariant={modalVariant}
|
||||
>
|
||||
<StyledModalDiv
|
||||
ref={modalRef}
|
||||
size={size}
|
||||
padding={padding}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
layout
|
||||
modalVariant={modalVariant}
|
||||
variants={modalAnimation}
|
||||
transition={{ duration: theme.animation.duration.normal }}
|
||||
className={className}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
{children}
|
||||
</StyledModalDiv>
|
||||
</StyledBackDrop>
|
||||
<>
|
||||
{isModalOpened && (
|
||||
<ModalComponentInstanceContext.Provider
|
||||
value={{
|
||||
instanceId: modalId,
|
||||
}}
|
||||
>
|
||||
<ModalHotkeysAndClickOutsideEffect
|
||||
modalId={modalId}
|
||||
modalRef={modalRef}
|
||||
onEnter={onEnter}
|
||||
isClosable={isClosable}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<StyledBackDrop
|
||||
data-testid="modal-backdrop"
|
||||
className="modal-backdrop"
|
||||
onMouseDown={stopEventPropagation}
|
||||
modalVariant={modalVariant}
|
||||
>
|
||||
<StyledModalDiv
|
||||
ref={modalRef}
|
||||
size={size}
|
||||
padding={padding}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
layout
|
||||
modalVariant={modalVariant}
|
||||
variants={modalAnimation}
|
||||
transition={{ duration: theme.animation.duration.normal }}
|
||||
className={className}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
{children}
|
||||
</StyledModalDiv>
|
||||
</StyledBackDrop>
|
||||
</ModalComponentInstanceContext.Provider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import {
|
||||
ClickOutsideMode,
|
||||
useListenClickOutside,
|
||||
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
type ModalHotkeysAndClickOutsideEffectProps = {
|
||||
modalRef: React.RefObject<HTMLDivElement>;
|
||||
onEnter?: () => void;
|
||||
isClosable?: boolean;
|
||||
onClose?: () => void;
|
||||
modalId: string;
|
||||
};
|
||||
|
||||
export const ModalHotkeysAndClickOutsideEffect = ({
|
||||
modalRef,
|
||||
onEnter,
|
||||
isClosable = false,
|
||||
onClose,
|
||||
modalId,
|
||||
}: ModalHotkeysAndClickOutsideEffectProps) => {
|
||||
useScopedHotkeys(
|
||||
[Key.Enter],
|
||||
() => {
|
||||
onEnter?.();
|
||||
},
|
||||
ModalHotkeyScope.ModalFocus,
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape],
|
||||
() => {
|
||||
if (isClosable && onClose !== undefined) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
ModalHotkeyScope.ModalFocus,
|
||||
);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [modalRef],
|
||||
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`,
|
||||
callback: () => {
|
||||
if (isClosable && onClose !== undefined) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
mode: ClickOutsideMode.compareHTMLRef,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -1,26 +1,58 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ConfirmationModal } from '../ConfirmationModal';
|
||||
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
||||
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
|
||||
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { RootDecorator } from '~/testing/decorators/RootDecorator';
|
||||
import { isModalOpenedComponentState } from '../../states/isModalOpenedComponentState';
|
||||
import { ConfirmationModal } from '../ConfirmationModal';
|
||||
|
||||
const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => {
|
||||
set(
|
||||
isModalOpenedComponentState.atomFamily({
|
||||
instanceId: 'confirmation-modal',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
set(currentHotkeyScopeState, {
|
||||
scope: ModalHotkeyScope.ModalFocus,
|
||||
customScopes: {
|
||||
commandMenu: true,
|
||||
goto: false,
|
||||
keyboardShortcutMenu: false,
|
||||
},
|
||||
});
|
||||
|
||||
set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ConfirmationModal> = {
|
||||
title: 'UI/Layout/Modal/ConfirmationModal',
|
||||
component: ConfirmationModal,
|
||||
decorators: [ComponentDecorator, I18nFrontDecorator],
|
||||
decorators: [RootDecorator, ComponentDecorator, I18nFrontDecorator],
|
||||
parameters: {
|
||||
initializeState,
|
||||
disableHotkeyInitialization: true,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ConfirmationModal>;
|
||||
|
||||
const closeMock = fn();
|
||||
const confirmMock = fn();
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isOpen: true,
|
||||
modalId: 'confirmation-modal',
|
||||
title: 'Pariatur labore.',
|
||||
subtitle: 'Velit dolore aliquip laborum occaecat fugiat.',
|
||||
confirmButtonText: 'Delete',
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const InputConfirmation: Story = {
|
||||
@ -29,5 +61,120 @@ export const InputConfirmation: Story = {
|
||||
confirmationPlaceholder: 'email@test.dev',
|
||||
...Default.args,
|
||||
},
|
||||
decorators: Default.decorators,
|
||||
};
|
||||
|
||||
export const CloseOnEscape: Story = {
|
||||
args: {
|
||||
modalId: 'confirmation-modal',
|
||||
title: 'Escape Key Test',
|
||||
subtitle: 'This modal should close when pressing the Escape key.',
|
||||
confirmButtonText: 'Confirm',
|
||||
onClose: closeMock,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Escape Key Test');
|
||||
|
||||
closeMock.mockClear();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const CloseOnClickOutside: Story = {
|
||||
args: {
|
||||
modalId: 'confirmation-modal',
|
||||
title: 'Click Outside Test',
|
||||
subtitle: 'This modal should close when clicking outside of it.',
|
||||
confirmButtonText: 'Confirm',
|
||||
onClose: closeMock,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Click Outside Test');
|
||||
|
||||
const backdrop = await canvas.findByTestId('modal-backdrop');
|
||||
|
||||
await userEvent.click(backdrop);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ConfirmWithEnterKey: Story = {
|
||||
args: {
|
||||
modalId: 'confirmation-modal',
|
||||
title: 'Enter Key Test',
|
||||
subtitle: 'This modal should confirm when pressing the Enter key.',
|
||||
confirmButtonText: 'Confirm',
|
||||
onConfirmClick: confirmMock,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Enter Key Test');
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const CancelButtonClick: Story = {
|
||||
args: {
|
||||
modalId: 'confirmation-modal',
|
||||
title: 'Cancel Button Test',
|
||||
subtitle: 'Clicking the cancel button should close the modal',
|
||||
confirmButtonText: 'Confirm',
|
||||
onClose: closeMock,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Cancel Button Test');
|
||||
|
||||
const cancelButton = await canvas.findByRole('button', {
|
||||
name: /Cancel/,
|
||||
});
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ConfirmButtonClick: Story = {
|
||||
args: {
|
||||
modalId: 'confirmation-modal',
|
||||
title: 'Confirm Button Test',
|
||||
subtitle: 'Clicking the confirm button should trigger the confirm action',
|
||||
confirmButtonText: 'Confirm',
|
||||
onConfirmClick: confirmMock,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Confirm Button Test');
|
||||
|
||||
const confirmButton = await canvas.findByRole('button', {
|
||||
name: /Confirm/,
|
||||
});
|
||||
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,18 +1,53 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
|
||||
import { Modal } from '../Modal';
|
||||
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
||||
import { currentHotkeyScopeState } from '@/ui/utilities/hotkey/states/internal/currentHotkeyScopeState';
|
||||
import { internalHotkeysEnabledScopesState } from '@/ui/utilities/hotkey/states/internal/internalHotkeysEnabledScopesState';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { RootDecorator } from '~/testing/decorators/RootDecorator';
|
||||
import { isModalOpenedComponentState } from '../../states/isModalOpenedComponentState';
|
||||
import { Modal } from '../Modal';
|
||||
|
||||
const initializeState = ({ set }: { set: (atom: any, value: any) => void }) => {
|
||||
set(
|
||||
isModalOpenedComponentState.atomFamily({
|
||||
instanceId: 'modal-id',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
set(currentHotkeyScopeState, {
|
||||
scope: ModalHotkeyScope.ModalFocus,
|
||||
customScopes: {
|
||||
commandMenu: true,
|
||||
goto: false,
|
||||
keyboardShortcutMenu: false,
|
||||
},
|
||||
});
|
||||
|
||||
set(internalHotkeysEnabledScopesState, [ModalHotkeyScope.ModalFocus]);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof Modal> = {
|
||||
title: 'UI/Layout/Modal/Modal',
|
||||
component: Modal,
|
||||
decorators: [I18nFrontDecorator, RootDecorator, ComponentDecorator],
|
||||
parameters: {
|
||||
initializeState,
|
||||
disableHotkeyInitialization: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Modal>;
|
||||
|
||||
const closeMock = fn();
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
modalId: 'modal-id',
|
||||
size: 'medium',
|
||||
padding: 'medium',
|
||||
children: (
|
||||
@ -29,8 +64,63 @@ export const Default: Story = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
argTypes: {
|
||||
children: { control: false },
|
||||
};
|
||||
|
||||
export const CloseClosableModalOnClickOutside: Story = {
|
||||
args: {
|
||||
modalId: 'modal-id',
|
||||
size: 'medium',
|
||||
padding: 'medium',
|
||||
isClosable: true,
|
||||
onClose: closeMock,
|
||||
children: (
|
||||
<>
|
||||
<Modal.Header>Click Outside Test</Modal.Header>
|
||||
<Modal.Content>
|
||||
This modal should close when clicking outside of it.
|
||||
</Modal.Content>
|
||||
</>
|
||||
),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Click Outside Test');
|
||||
|
||||
const backdrop = document.querySelector('.modal-backdrop') as HTMLElement;
|
||||
await userEvent.click(backdrop);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const CloseClosableModalOnEscape: Story = {
|
||||
args: {
|
||||
modalId: 'modal-id',
|
||||
size: 'medium',
|
||||
padding: 'medium',
|
||||
isClosable: true,
|
||||
onClose: closeMock,
|
||||
children: (
|
||||
<>
|
||||
<Modal.Header>Escape Key Test</Modal.Header>
|
||||
<Modal.Content>
|
||||
This modal should close when pressing the Escape key.
|
||||
</Modal.Content>
|
||||
</>
|
||||
),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('Escape Key Test');
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export enum ModalHotkeyScope {
|
||||
Default = 'default',
|
||||
ModalFocus = 'modal-focus',
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const ModalComponentInstanceContext = createComponentInstanceContext();
|
||||
@ -0,0 +1,181 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
|
||||
import { act } from 'react';
|
||||
|
||||
jest.mock('@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope');
|
||||
|
||||
const mockSetHotkeyScopeAndMemorizePreviousScope = jest.fn();
|
||||
const mockGoBackToPreviousHotkeyScope = jest.fn();
|
||||
|
||||
const modalId = 'test-modal-id';
|
||||
const customHotkeyScope: HotkeyScope = {
|
||||
scope: 'test-scope',
|
||||
customScopes: {
|
||||
goto: true,
|
||||
commandMenu: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('useModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(usePreviousHotkeyScope as jest.Mock).mockReturnValue({
|
||||
setHotkeyScopeAndMemorizePreviousScope:
|
||||
mockSetHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope: mockGoBackToPreviousHotkeyScope,
|
||||
});
|
||||
});
|
||||
|
||||
it('should open a modal', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const modal = useModal();
|
||||
const isModalOpened = useRecoilValue(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
);
|
||||
return { modal, isModalOpened };
|
||||
},
|
||||
{
|
||||
wrapper: RecoilRoot,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.openModal(modalId);
|
||||
});
|
||||
|
||||
expect(result.current.isModalOpened).toBe(true);
|
||||
});
|
||||
|
||||
it('should open a modal with custom hotkey scope', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const modal = useModal();
|
||||
const isModalOpened = useRecoilValue(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
);
|
||||
return { modal, isModalOpened };
|
||||
},
|
||||
{
|
||||
wrapper: RecoilRoot,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.openModal(modalId, customHotkeyScope);
|
||||
});
|
||||
|
||||
expect(result.current.isModalOpened).toBe(true);
|
||||
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
|
||||
customHotkeyScope.scope,
|
||||
customHotkeyScope.customScopes,
|
||||
);
|
||||
});
|
||||
|
||||
it('should close a modal', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const modal = useModal();
|
||||
const isModalOpened = useRecoilValue(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
);
|
||||
return { modal, isModalOpened };
|
||||
},
|
||||
{
|
||||
wrapper: RecoilRoot,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.openModal(modalId);
|
||||
});
|
||||
expect(result.current.isModalOpened).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.closeModal(modalId);
|
||||
});
|
||||
|
||||
expect(result.current.isModalOpened).toBe(false);
|
||||
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle a modal (open when closed)', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const modal = useModal();
|
||||
const isModalOpened = useRecoilValue(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
);
|
||||
return { modal, isModalOpened };
|
||||
},
|
||||
{
|
||||
wrapper: RecoilRoot,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.isModalOpened).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.toggleModal(modalId);
|
||||
});
|
||||
|
||||
expect(result.current.isModalOpened).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle a modal (close when open)', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const modal = useModal();
|
||||
const isModalOpened = useRecoilValue(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
);
|
||||
return { modal, isModalOpened };
|
||||
},
|
||||
{
|
||||
wrapper: RecoilRoot,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.openModal(modalId);
|
||||
});
|
||||
expect(result.current.isModalOpened).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.toggleModal(modalId);
|
||||
});
|
||||
|
||||
expect(result.current.isModalOpened).toBe(false);
|
||||
expect(mockGoBackToPreviousHotkeyScope).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle a modal with custom hotkey scope', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const modal = useModal();
|
||||
const isModalOpened = useRecoilValue(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
);
|
||||
return { modal, isModalOpened };
|
||||
},
|
||||
{
|
||||
wrapper: RecoilRoot,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.modal.toggleModal(modalId, customHotkeyScope);
|
||||
});
|
||||
|
||||
expect(result.current.isModalOpened).toBe(true);
|
||||
expect(mockSetHotkeyScopeAndMemorizePreviousScope).toHaveBeenCalledWith(
|
||||
customHotkeyScope.scope,
|
||||
customHotkeyScope.customScopes,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,93 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
|
||||
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useModal = () => {
|
||||
const {
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
} = usePreviousHotkeyScope('modal');
|
||||
|
||||
const closeModal = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
(modalId: string) => {
|
||||
const isModalOpen = snapshot
|
||||
.getLoadable(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
if (isModalOpen) {
|
||||
goBackToPreviousHotkeyScope();
|
||||
set(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
[goBackToPreviousHotkeyScope],
|
||||
);
|
||||
|
||||
const openModal = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
(modalId: string, customHotkeyScope?: HotkeyScope) => {
|
||||
const isModalOpened = snapshot
|
||||
.getLoadable(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
if (isModalOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
true,
|
||||
);
|
||||
|
||||
if (isDefined(customHotkeyScope)) {
|
||||
setHotkeyScopeAndMemorizePreviousScope(
|
||||
customHotkeyScope.scope,
|
||||
customHotkeyScope.customScopes,
|
||||
);
|
||||
} else {
|
||||
setHotkeyScopeAndMemorizePreviousScope(ModalHotkeyScope.ModalFocus, {
|
||||
goto: false,
|
||||
commandMenu: false,
|
||||
commandMenuOpen: false,
|
||||
keyboardShortcutMenu: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setHotkeyScopeAndMemorizePreviousScope],
|
||||
);
|
||||
|
||||
const toggleModal = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
(modalId: string, customHotkeyScope?: HotkeyScope) => {
|
||||
const isModalOpen = snapshot
|
||||
.getLoadable(
|
||||
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
if (isModalOpen) {
|
||||
closeModal(modalId);
|
||||
} else {
|
||||
openModal(modalId, customHotkeyScope);
|
||||
}
|
||||
},
|
||||
[closeModal, openModal],
|
||||
);
|
||||
|
||||
return {
|
||||
closeModal,
|
||||
openModal,
|
||||
toggleModal,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
|
||||
import { ModalComponentInstanceContext } from '../contexts/ModalComponentInstanceContext';
|
||||
|
||||
export const isModalOpenedComponentState = createComponentStateV2<boolean>({
|
||||
key: 'isModalOpenedComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: ModalComponentInstanceContext,
|
||||
});
|
||||
Reference in New Issue
Block a user