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,5 +1,4 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -10,6 +9,7 @@ import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { isDefined } from 'twenty-shared/utils';
@ -29,6 +29,8 @@ import {
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const SWITCH_BILLING_INTERVAL_MODAL_ID = 'switch-billing-interval-modal';
export const SettingsBilling = () => {
const { t } = useLingui();
@ -47,8 +49,6 @@ export const SettingsBilling = () => {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const [isSwitchingIntervalModalOpen, setIsSwitchingIntervalModalOpen] =
useState(false);
const [switchToYearlyInterval] =
useSwitchSubscriptionToYearlyIntervalMutation();
const { data, loading } = useBillingPortalSessionQuery({
@ -67,9 +67,7 @@ export const SettingsBilling = () => {
}
};
const openSwitchingIntervalModal = () => {
setIsSwitchingIntervalModalOpen(true);
};
const { openModal } = useModal();
const switchInterval = async () => {
try {
@ -133,7 +131,7 @@ export const SettingsBilling = () => {
Icon={IconCalendarEvent}
title={t`Switch to yearly`}
variant="secondary"
onClick={openSwitchingIntervalModal}
onClick={() => openModal(SWITCH_BILLING_INTERVAL_MODAL_ID)}
disabled={!hasNotCanceledCurrentSubscription}
/>
</Section>
@ -154,8 +152,7 @@ export const SettingsBilling = () => {
</Section>
</SettingsPageContainer>
<ConfirmationModal
isOpen={isSwitchingIntervalModalOpen}
setIsOpen={setIsSwitchingIntervalModalOpen}
modalId={SWITCH_BILLING_INTERVAL_MODAL_ID}
title={t`Switch billing to yearly`}
subtitle={t`Are you sure that you want to change your billing interval? You will be charged immediately for the full year.`}
onConfirmClick={switchInterval}

View File

@ -16,6 +16,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@ -23,13 +24,6 @@ import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
import { formatDistanceToNow } from 'date-fns';
import { useGetWorkspaceInvitationsQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { TableCell } from '../../modules/ui/layout/table/components/TableCell';
import { TableRow } from '../../modules/ui/layout/table/components/TableRow';
import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
import { useResendWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation';
import { workspaceInvitationsState } from '../../modules/workspace-invitation/states/workspaceInvitationsStates';
import { isDefined } from 'twenty-shared/utils';
import {
AppTooltip,
@ -44,6 +38,16 @@ import {
} from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useGetWorkspaceInvitationsQuery } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { TableCell } from '../../modules/ui/layout/table/components/TableCell';
import { TableRow } from '../../modules/ui/layout/table/components/TableRow';
import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
import { useResendWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation';
import { workspaceInvitationsState } from '../../modules/workspace-invitation/states/workspaceInvitationsStates';
export const WORKSPACE_MEMBER_DELETION_MODAL_ID =
'workspace-member-deletion-modal';
const StyledButtonContainer = styled.div`
align-items: center;
@ -92,7 +96,6 @@ export const SettingsWorkspaceMembers = () => {
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [workspaceMemberToDelete, setWorkspaceMemberToDelete] = useState<
string | undefined
>();
@ -112,7 +115,6 @@ export const SettingsWorkspaceMembers = () => {
const handleRemoveWorkspaceMember = async (workspaceMemberId: string) => {
await deleteOneWorkspaceMember?.(workspaceMemberId);
setIsConfirmationModalOpen(false);
};
const workspaceInvitations = useRecoilValue(workspaceInvitationsState);
@ -177,6 +179,8 @@ export const SettingsWorkspaceMembers = () => {
);
});
const { openModal } = useModal();
return (
<SubMenuTopBarContainer
title={t`Members`}
@ -273,7 +277,7 @@ export const SettingsWorkspaceMembers = () => {
<StyledButtonContainer>
<IconButton
onClick={() => {
setIsConfirmationModalOpen(true);
openModal(WORKSPACE_MEMBER_DELETION_MODAL_ID);
setWorkspaceMemberToDelete(workspaceMember.id);
}}
variant="tertiary"
@ -371,8 +375,7 @@ export const SettingsWorkspaceMembers = () => {
</Section>
</SettingsPageContainer>
<ConfirmationModal
isOpen={isConfirmationModalOpen}
setIsOpen={setIsConfirmationModalOpen}
modalId={WORKSPACE_MEMBER_DELETION_MODAL_ID}
title={t`Account Deletion`}
subtitle={
<Trans>

View File

@ -12,6 +12,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useRecoilValue } from 'recoil';
import { ConfigVariableValue } from 'twenty-shared/types';
@ -23,7 +24,6 @@ import {
useGetDatabaseConfigVariableQuery,
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledForm = styled(Form)`
display: flex;
flex-direction: column;
@ -48,11 +48,13 @@ const StyledButtonContainer = styled.div`
}
`;
const RESET_VARIABLE_MODAL_ID = 'reset-variable-modal';
export const SettingsAdminConfigVariableDetails = () => {
const { variableName } = useParams();
const { t } = useLingui();
const [isEditing, setIsEditing] = useState(false);
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const { openModal } = useModal();
const isConfigVariablesInDbEnabled = useRecoilValue(
isConfigVariablesInDbEnabledState,
);
@ -101,7 +103,7 @@ export const SettingsAdminConfigVariableDetails = () => {
}
if (isFromDatabase && !hasValueChanged) {
setIsConfirmationModalOpen(true);
openModal(RESET_VARIABLE_MODAL_ID);
return;
}
@ -193,13 +195,7 @@ export const SettingsAdminConfigVariableDetails = () => {
</SubMenuTopBarContainer>
<ConfirmationModal
isOpen={isConfirmationModalOpen}
setIsOpen={(isOpen) => {
setIsConfirmationModalOpen(isOpen);
if (!isOpen) {
setIsEditing(false);
}
}}
modalId={RESET_VARIABLE_MODAL_ID}
title={t`Reset variable`}
subtitle={t`This will revert the database value to environment/default value. The database override will be removed and the system will use the environment settings.`}
onConfirmClick={handleConfirmReset}

View File

@ -21,14 +21,15 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Trans, useLingui } from '@lingui/react/macro';
import { H2Title, IconRepeat, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { Button } from 'twenty-ui/input';
import { H2Title, IconRepeat, IconTrash } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light};
@ -44,12 +45,13 @@ const StyledInputContainer = styled.div`
width: 100%;
`;
const DELETE_API_KEY_MODAL_ID = 'delete-api-key-modal';
const REGENERATE_API_KEY_MODAL_ID = 'regenerate-api-key-modal';
export const SettingsDevelopersApiKeyDetail = () => {
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
const [isRegenerateKeyModalOpen, setIsRegenerateKeyModalOpen] =
useState(false);
const [isDeleteApiKeyModalOpen, setIsDeleteApiKeyModalOpen] = useState(false);
const { openModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigateSettings();
@ -194,7 +196,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
<Button
title={t`Regenerate Key`}
Icon={IconRepeat}
onClick={() => setIsRegenerateKeyModalOpen(true)}
onClick={() => openModal(REGENERATE_API_KEY_MODAL_ID)}
/>
<StyledInfo>
{formatExpiration(
@ -242,7 +244,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
onClick={() => setIsDeleteApiKeyModalOpen(true)}
onClick={() => openModal(DELETE_API_KEY_MODAL_ID)}
/>
</Section>
</SettingsPageContainer>
@ -251,8 +253,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
isOpen={isDeleteApiKeyModalOpen}
setIsOpen={setIsDeleteApiKeyModalOpen}
modalId={DELETE_API_KEY_MODAL_ID}
title={t`Delete API key`}
subtitle={
<Trans>
@ -268,8 +269,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
isOpen={isRegenerateKeyModalOpen}
setIsOpen={setIsRegenerateKeyModalOpen}
modalId={REGENERATE_API_KEY_MODAL_ID}
title={t`Regenerate an API key`}
subtitle={
<Trans>

View File

@ -1,6 +1,6 @@
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
@ -11,11 +11,10 @@ import { Select } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Trans, useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { Button, IconButton, SelectOption } from 'twenty-ui/input';
import {
H2Title,
IconBox,
@ -25,8 +24,9 @@ import {
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { Button, IconButton, SelectOption } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const OBJECT_DROPDOWN_WIDTH = 340;
const ACTION_DROPDOWN_WIDTH = 140;
const OBJECT_MOBILE_WIDTH = 150;
@ -48,6 +48,8 @@ const StyledPlaceholder = styled.div`
width: ${({ theme }) => theme.spacing(8)};
`;
const DELETE_WEBHOOK_MODAL_ID = 'delete-webhook-modal';
export const SettingsDevelopersWebhooksDetail = () => {
const { t } = useLingui();
@ -77,8 +79,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
isCreationMode,
});
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] =
useState(false);
const { openModal } = useModal();
const fieldTypeOptions: SelectOption<string>[] = useMemo(
() => [
@ -219,13 +220,12 @@ export const SettingsDevelopersWebhooksDetail = () => {
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
onClick={() => setIsDeleteWebhookModalOpen(true)}
onClick={() => openModal(DELETE_WEBHOOK_MODAL_ID)}
/>
<ConfirmationModal
confirmationPlaceholder={confirmationText}
confirmationValue={confirmationText}
isOpen={isDeleteWebhookModalOpen}
setIsOpen={setIsDeleteWebhookModalOpen}
modalId={DELETE_WEBHOOK_MODAL_ID}
title={t`Delete webhook`}
subtitle={
<Trans>

View File

@ -9,12 +9,12 @@ import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { ApolloError } from '@apollo/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@ -28,6 +28,9 @@ import { SettingsCustomDomain } from '~/pages/settings/workspace/SettingsCustomD
import { SettingsSubdomain } from '~/pages/settings/workspace/SettingsSubdomain';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
'subdomain-change-confirmation-modal';
export const SettingsDomain = () => {
const navigate = useNavigateSettings();
const { t } = useLingui();
@ -73,10 +76,7 @@ export const SettingsDomain = () => {
currentWorkspaceState,
);
const [
isSubdomainChangeConfirmationModalOpen,
setIsSubdomainChangeConfirmationModalOpen,
] = useState(false);
const { openModal } = useModal();
const form = useForm<{
subdomain: string;
@ -201,7 +201,7 @@ export const SettingsDomain = () => {
isDefined(values.subdomain) &&
values.subdomain !== currentWorkspace.subdomain
) {
setIsSubdomainChangeConfirmationModalOpen(true);
openModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID);
return;
}
@ -240,10 +240,9 @@ export const SettingsDomain = () => {
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
isOpen={isSubdomainChangeConfirmationModalOpen}
modalId={SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID}
title={t`Change subdomain?`}
subtitle={t`You're about to change your workspace subdomain. This action will log out all users.`}
setIsOpen={setIsSubdomainChangeConfirmationModalOpen}
onConfirmClick={() => {
const values = form.getValues();
currentWorkspace &&