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:
Raphaël Bosi
2025-05-16 17:04:22 +02:00
committed by GitHub
parent 8334fe9528
commit 6554947671
94 changed files with 1268 additions and 563 deletions

View File

@ -1,7 +1,7 @@
import styled from '@emotion/styled';
import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350
import { useContext, useRef } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader';
@ -19,11 +19,12 @@ import { RecordBoardComponentInstanceContext } from '@/object-record/record-boar
import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition';
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -120,9 +121,7 @@ export const RecordBoard = () => {
},
});
const setIsRemoveSortingModalOpen = useSetRecoilState(
isRemoveSortingModalOpenState,
);
const { openModal } = useModal();
const handleDragEnd: OnDragEndResponder = useRecoilCallback(
({ snapshot }) =>
@ -130,7 +129,7 @@ export const RecordBoard = () => {
if (!result.destination) return;
if (currentRecordSorts.length > 0) {
setIsRemoveSortingModalOpen(true);
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
return;
}
@ -189,7 +188,7 @@ export const RecordBoard = () => {
recordIndexRecordIdsByGroupFamilyState,
selectFieldMetadataItem,
updateOneRecord,
setIsRemoveSortingModalOpen,
openModal,
currentRecordSorts,
],
);

View File

@ -1,9 +1,8 @@
import { isRecordGroupReorderConfirmationModalVisibleState } from '@/object-record/record-group/states/isRecordGroupReorderConfirmationModalVisibleState';
import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId';
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { createPortal } from 'react-dom';
import { useRecoilState } from 'recoil';
type RecordGroupReorderConfirmationModalProps = {
onConfirmClick: () => void;
@ -12,23 +11,13 @@ type RecordGroupReorderConfirmationModalProps = {
export const RecordGroupReorderConfirmationModal = ({
onConfirmClick,
}: RecordGroupReorderConfirmationModalProps) => {
const [
isRecordGroupReorderConfirmationModalVisible,
setIsRecordGroupReorderConfirmationModalVisible,
] = useRecoilState(isRecordGroupReorderConfirmationModalVisibleState);
const recordGroupSort = useRecoilComponentValueV2(
recordIndexRecordGroupSortComponentState,
);
if (!isRecordGroupReorderConfirmationModalVisible) {
return null;
}
return createPortal(
<ConfirmationModal
isOpen={isRecordGroupReorderConfirmationModalVisible}
setIsOpen={setIsRecordGroupReorderConfirmationModalVisible}
modalId={RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID}
title="Group sorting"
subtitle={`Would you like to remove ${recordGroupSort} group sorting?`}
onConfirmClick={onConfirmClick}

View File

@ -0,0 +1,2 @@
export const RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID =
'record-group-reorder-confirmation-modal';

View File

@ -1,16 +1,16 @@
import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId';
import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useReorderRecordGroups';
import { isRecordGroupReorderConfirmationModalVisibleState } from '@/object-record/record-group/states/isRecordGroupReorderConfirmationModalVisibleState';
import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort';
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
import { recordIndexRecordGroupIsDraggableSortComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewType } from '@/views/types/ViewType';
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useState } from 'react';
import { useSetRecoilState } from 'recoil';
type UseRecordGroupReorderConfirmationModalParams = {
recordIndexId: string;
@ -26,9 +26,7 @@ export const useRecordGroupReorderConfirmationModal = ({
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const setIsRecordGroupReorderConfirmationModalVisible = useSetRecoilState(
isRecordGroupReorderConfirmationModalVisibleState,
);
const { openModal } = useModal();
const [pendingDragEndHandlerParams, setPendingDragEndHandlerParams] =
useState<Parameters<OnDragEndResponder> | null>(null);
@ -58,7 +56,7 @@ export const useRecordGroupReorderConfirmationModal = ({
const handleDragEndWithModal: OnDragEndResponder = (result, provided) => {
if (!isDragableSortRecordGroup) {
setIsRecordGroupReorderConfirmationModalVisible(true);
openModal(RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID);
setActiveDropdownFocusIdAndMemorizePrevious(null);
setPendingDragEndHandlerParams([result, provided]);
} else {

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const isRecordGroupReorderConfirmationModalVisibleState = atom<boolean>({
key: 'isRecordGroupReorderConfirmationModalVisibleState',
default: false,
});

View File

@ -9,7 +9,10 @@ import { RecordBoardBodyEscapeHotkeyEffect } from '@/object-record/record-board/
import { RecordBoardHotkeyEffect } from '@/object-record/record-board/components/RecordBoardHotkeyEffect';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
type RecordIndexBoardContainerProps = {
recordBoardId: string;
viewBarId: string;
@ -39,6 +42,11 @@ export const RecordIndexBoardContainer = ({
shouldMatchRootQueryFilter: true,
});
const isRecordIndexRemoveSortingModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
RECORD_INDEX_REMOVE_SORTING_MODAL_ID,
);
if (!selectFieldMetadataItem) {
return;
}
@ -55,7 +63,9 @@ export const RecordIndexBoardContainer = ({
}}
>
<RecordBoard />
<RecordIndexRemoveSortingModal />
{isRecordIndexRemoveSortingModalOpened && (
<RecordIndexRemoveSortingModal />
)}
<RecordBoardHotkeyEffect />
<RecordBoardBodyEscapeHotkeyEffect />
</RecordBoardContext.Provider>

View File

@ -1,8 +1,6 @@
import { useRecoilState } from 'recoil';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -14,8 +12,6 @@ export const RecordIndexRemoveSortingModal = () => {
const fieldMetadataIds = currentRecordSorts.map(
(viewSort) => viewSort.fieldMetadataId,
);
const [isRemoveSortingModalOpen, setIsRemoveSortingModalOpen] =
useRecoilState(isRemoveSortingModalOpenState);
const { removeRecordSort } = useRemoveRecordSort();
@ -26,15 +22,12 @@ export const RecordIndexRemoveSortingModal = () => {
};
return (
<>
<ConfirmationModal
isOpen={isRemoveSortingModalOpen}
setIsOpen={setIsRemoveSortingModalOpen}
title={'Remove sorting?'}
subtitle={'This is required to enable manual row reordering.'}
onConfirmClick={handleRemoveClick}
confirmButtonText={'Remove Sorting'}
/>
</>
<ConfirmationModal
modalId={RECORD_INDEX_REMOVE_SORTING_MODAL_ID}
title={'Remove sorting?'}
subtitle={'This is required to enable manual row reordering.'}
onConfirmClick={handleRemoveClick}
confirmButtonText={'Remove Sorting'}
/>
);
};

View File

@ -1,8 +1,11 @@
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordUpdateHookParams } from '@/object-record/record-field/contexts/FieldContext';
import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
type RecordIndexTableContainerProps = {
recordTableId: string;
@ -15,6 +18,11 @@ export const RecordIndexTableContainer = ({
}: RecordIndexTableContainerProps) => {
const { objectNameSingular } = useRecordIndexContextOrThrow();
const isRecordIndexRemoveSortingModalOpened = useRecoilComponentValueV2(
isModalOpenedComponentState,
RECORD_INDEX_REMOVE_SORTING_MODAL_ID,
);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular,
});
@ -34,7 +42,9 @@ export const RecordIndexTableContainer = ({
viewBarId={viewBarId}
updateRecordMutation={updateEntity}
/>
<RecordIndexRemoveSortingModal />
{isRecordIndexRemoveSortingModalOpened && (
<RecordIndexRemoveSortingModal />
)}
</>
);
};

View File

@ -0,0 +1,2 @@
export const RECORD_INDEX_REMOVE_SORTING_MODAL_ID =
'record-index-remove-sorting-modal';

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useCallback, useContext, useState } from 'react';
import { useCallback, useContext } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
@ -34,6 +34,7 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { createPortal } from 'react-dom';
import {
IconChevronDown,
@ -87,6 +88,8 @@ const StyledClickableZone = styled.div`
const MotionIconChevronDown = motion.create(IconChevronDown);
const DELETE_RELATION_MODAL_ID = 'delete-relation-modal';
type RecordDetailRelationRecordsListItemProps = {
isExpanded: boolean;
onClick: (relationRecordId: string) => void;
@ -100,8 +103,7 @@ export const RecordDetailRelationRecordsListItem = ({
}: RecordDetailRelationRecordsListItemProps) => {
const { fieldDefinition } = useContext(FieldContext);
const [isDeleteRelationModalOpen, setIsDeleteRelationModalOpen] =
useState(false);
const { openModal } = useModal();
const {
relationFieldMetadataId,
@ -173,13 +175,12 @@ export const RecordDetailRelationRecordsListItem = ({
};
const handleDelete = async () => {
setIsDeleteRelationModalOpen(true);
closeDropdown();
openModal(DELETE_RELATION_MODAL_ID);
};
const handleConfirmDelete = async () => {
await deleteOneRelationRecord(relationRecord.id);
setIsDeleteRelationModalOpen(false);
};
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
@ -306,8 +307,7 @@ export const RecordDetailRelationRecordsListItem = ({
</AnimatedEaseInOut>
{createPortal(
<ConfirmationModal
isOpen={isDeleteRelationModalOpen}
setIsOpen={setIsDeleteRelationModalOpen}
modalId={DELETE_RELATION_MODAL_ID}
title={`Delete Related ${relationObjectTypeName}`}
subtitle={
<>

View File

@ -1,14 +1,15 @@
import { DragDropContext, DropResult } from '@hello-pangea/dnd';
import { ReactNode } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -33,15 +34,13 @@ export const RecordTableBodyDragDropContextProvider = ({
currentRecordSortsComponentState,
);
const setIsRemoveSortingModalOpen = useSetRecoilState(
isRemoveSortingModalOpenState,
);
const { openModal } = useModal();
const handleDragEnd = useRecoilCallback(
({ snapshot }) =>
(result: DropResult) => {
if (currentRecordSorts.length > 0) {
setIsRemoveSortingModalOpen(true);
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
return;
}
@ -100,10 +99,10 @@ export const RecordTableBodyDragDropContextProvider = ({
});
},
[
currentRecordSorts.length,
recordIndexAllRecordIdsSelector,
setIsRemoveSortingModalOpen,
updateOneRow,
currentRecordSorts,
openModal,
],
);

View File

@ -1,15 +1,16 @@
import { DragDropContext, DropResult } from '@hello-pangea/dnd';
import { ReactNode } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition';
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { isDefined } from 'twenty-shared/utils';
@ -26,9 +27,7 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({
objectNameSingular,
});
const setIsRemoveSortingModalOpen = useSetRecoilState(
isRemoveSortingModalOpenState,
);
const { openModal } = useModal();
const recordIdsByGroupFamilyState = useRecoilComponentCallbackStateV2(
recordIndexRecordIdsByGroupComponentFamilyState,
@ -73,7 +72,7 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({
}
if (currentRecordSorts.length > 0) {
setIsRemoveSortingModalOpen(true);
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
return;
}
@ -130,11 +129,11 @@ export const RecordTableBodyRecordGroupDragDropContextProvider = ({
});
},
[
currentRecordSortsCallbackState,
objectMetadataItem.fields,
recordIdsByGroupFamilyState,
updateOneRow,
setIsRemoveSortingModalOpen,
currentRecordSortsCallbackState,
openModal,
],
);

View File

@ -1,5 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const isRemoveSortingModalOpenState = createState<boolean>({
key: 'isRemoveSortingModalOpenState',
defaultValue: false,
});