Refactor modal (#18377)

## Summary

- Move Modal UI components (`Modal`, `ModalContent`, `ModalHeader`,
`ModalFooter`, `ModalBackdrop`) from `twenty-front` to `twenty-ui` as
stateless, reusable components
- Create `ModalStatefulWrapper` in `twenty-front` that connects Jotai
state (`isModalOpenedComponentState`) to the stateless `Modal` via an
`isOpen` prop
- Rename `modalVariant` prop to `overlay` with clearer values: `'dark'`
(default), `'light'` (in-container), `'transparent'` (invisible panel).
Remove unused `'medium'` overlay
- Rename `modalId` to `modalInstanceId` across the entire modal zone
(~30 consumer files)
- Extract `ModalProps` to its own file in
`twenty-ui/types/ModalProps.ts`; extract `ModalStatefulWrapperProps` to
its own file using `Pick<ModalProps, ...>` for shared props
- Extract `ModalBackdrop` to its own file and export from `twenty-ui`;
use it in `UserOrMetadataLoader` instead of a local styled component
- Use `ModalFooter` in `StepNavigationButton` and `ModalHeader` in
`SpreadsheetImportStepperContainer` instead of duplicated `styled.div`
definitions
- Remove unused `onClose` prop from stateless `Modal`; fix `typeof
document` guard in `ModalStatefulWrapper`
- Split shared types into individual files: `ModalSize.ts`,
`ModalPadding.ts`, `ModalOverlay.ts`
- Extract wyw profiling instrumentation from `vite.config.ts` into
reusable `createWywProfilingPlugin` with parametrized threshold and
improved logging
- Delete old `Modal.tsx`, `Modal.styles.ts`, `ModalContent.tsx`,
`ModalHeader.tsx`, `ModalFooter.tsx` from `twenty-front`
- Add comprehensive Storybook stories in `twenty-ui` covering Default,
Confirmation, Small, ExtraLarge, Closed, and Interactive variants
This commit is contained in:
Charles Bochet
2026-03-04 13:22:31 +01:00
committed by GitHub
parent 995793c0ac
commit 3b2bf39565
99 changed files with 4283 additions and 3688 deletions
@@ -1,9 +1,10 @@
import { styled } from '@linaria/react';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
import { ModalBackdrop } from 'twenty-ui/layout';
import { LeftPanelSkeletonLoader } from '~/loading/components/LeftPanelSkeletonLoader';
import { RightPanelSkeletonLoader } from '~/loading/components/RightPanelSkeletonLoader';
@@ -29,7 +30,12 @@ export const UserOrMetadataLoader = () => {
return (
<StyledContainer>
{showAuthModal && <Modal.Backdrop modalVariant="primary" />}
{showAuthModal && (
<ModalBackdrop
overlay="dark"
backdropZIndex={RootStackingContextZIndices.RootModalBackDrop}
/>
)}
<LeftPanelSkeletonLoader />
<RightPanelSkeletonLoader />
</StyledContainer>
@@ -65,7 +65,7 @@ export const ActionModal = ({
<ActionDisplay onClick={handleClick} />
{isModalOpened && (
<ConfirmationModal
modalId={modalId}
modalInstanceId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={handleConfirmClick}
@@ -9,7 +9,8 @@ import { type Attachment } from '@/activities/files/types/Attachment';
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { ModalContent, ModalHeader } from 'twenty-ui/layout';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -104,20 +105,6 @@ const StyledModalTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
`;
const StyledModalHeader = styled(Modal.Header)`
height: auto;
padding: 0;
`;
const StyledModalContent = styled(Modal.Content)`
padding: 0;
`;
const StyledModal = styled(Modal)`
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
const StyledButtonContainer = styled.div`
display: flex;
flex-direction: row;
@@ -231,14 +218,16 @@ export const AttachmentList = ({
{previewedAttachment &&
isAttachmentPreviewEnabled &&
createPortal(
<StyledModal
modalId={PREVIEW_MODAL_ID}
<ModalStatefulWrapper
modalInstanceId={PREVIEW_MODAL_ID}
size="large"
isClosable
onClose={handleClosePreview}
ignoreContainer
renderInDocumentBody
gap={2}
padding="small"
>
<StyledModalHeader>
<ModalHeader noPadding autoHeight>
<StyledHeader>
<StyledModalTitle>{previewedAttachment.name}</StyledModalTitle>
<StyledButtonContainer>
@@ -256,11 +245,11 @@ export const AttachmentList = ({
/>
</StyledButtonContainer>
</StyledHeader>
</StyledModalHeader>
</ModalHeader>
<ScrollWrapper
componentInstanceId={`preview-modal-${previewedAttachment.id}`}
>
<StyledModalContent>
<ModalContent noPadding>
<Suspense
fallback={
<StyledLoadingContainer>
@@ -275,9 +264,9 @@ export const AttachmentList = ({
documentUrl={getAttachmentUrl(previewedAttachment)}
/>
</Suspense>
</StyledModalContent>
</ModalContent>
</ScrollWrapper>
</StyledModal>,
</ModalStatefulWrapper>,
document.body,
)}
</>
@@ -1,7 +1,7 @@
import { AuthModalMountEffect } from '@/auth/components/AuthModalMountEffect';
import { AUTH_MODAL_ID } from '@/auth/constants/AuthModalId';
import { getAuthModalConfig } from '@/auth/utils/getAuthModalConfig';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { styled } from '@linaria/react';
import React from 'react';
@@ -23,11 +23,11 @@ export const AuthModal = ({ children }: AuthModalProps) => {
return (
<>
<AuthModalMountEffect />
<Modal
modalId={AUTH_MODAL_ID}
<ModalStatefulWrapper
modalInstanceId={AUTH_MODAL_ID}
padding="none"
size={config.size}
modalVariant={config.variant}
overlay={config.overlay}
>
{config.showScrollWrapper ? (
<ScrollWrapper componentInstanceId="scroll-wrapper-modal-content">
@@ -36,7 +36,7 @@ export const AuthModal = ({ children }: AuthModalProps) => {
) : (
<>{children}</>
)}
</Modal>
</ModalStatefulWrapper>
</>
);
};
@@ -8,7 +8,7 @@ import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -126,9 +126,9 @@ export const VerifyEmailEffect = () => {
if (isError) {
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
</Modal.Content>
</ModalContent>
);
}
@@ -5,14 +5,14 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
// Mock component that just renders the error state of VerifyEmailEffect directly
// (since normal VerifyEmailEffect has async logic that's hard to test in Storybook)
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const VerifyEmailEffectErrorState = ({ email = 'user@example.com' }) => {
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={email} isError={true} />
</Modal.Content>
</ModalContent>
);
};
@@ -29,7 +29,7 @@ const meta: Meta<typeof VerifyEmailEffectErrorState> = {
],
parameters: {
codeSection: {
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with Modal.Content to maintain consistent styling.',
docs: 'IMPORTANT: When rendering EmailVerificationSent from VerifyEmailEffect, always wrap it with ModalContent to maintain consistent styling.',
},
},
};
@@ -1,12 +1,9 @@
import {
type ModalSize,
type ModalVariants,
} from '@/ui/layout/modal/components/Modal';
import { type ModalOverlay, type ModalSize } from 'twenty-ui/layout';
import { AppPath } from 'twenty-shared/types';
type AuthModalConfigType = {
size: ModalSize;
variant: ModalVariants;
overlay: ModalOverlay;
showScrollWrapper: boolean;
};
@@ -16,12 +13,12 @@ export const AUTH_MODAL_CONFIG: {
} = {
default: {
size: 'medium',
variant: 'primary',
overlay: 'dark',
showScrollWrapper: true,
},
[AppPath.BookCall]: {
size: 'extraLarge',
variant: 'transparent',
overlay: 'transparent',
showScrollWrapper: false,
},
};
@@ -1,24 +1,17 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { EmailVerificationSent } from '@/auth/sign-in-up/components/EmailVerificationSent';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { ComponentDecorator } from 'twenty-ui/testing';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
// Wrap the component in Modal.Content to reflect how it's used in the app
const RenderWithModal = (
const RenderWithModalContent = (
args: React.ComponentProps<typeof EmailVerificationSent>,
) => {
return (
<Modal
modalId="email-verification-sent-modal"
padding="none"
modalVariant="primary"
>
<Modal.Content isVerticalCentered isHorizontalCentered>
<EmailVerificationSent email={args.email} isError={args.isError} />
</Modal.Content>
</Modal>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={args.email} isError={args.isError} />
</ModalContent>
);
};
@@ -28,10 +21,10 @@ const meta: Meta<typeof EmailVerificationSent> = {
decorators: [ComponentDecorator, SnackBarDecorator],
parameters: {
codeSection: {
docs: 'This component should always be wrapped with Modal.Content in the app.\n\nCorrect usage:\n```tsx\n<Modal.Content isVerticalCentered isHorizontalCentered>\n <EmailVerificationSent email={email} />\n</Modal.Content>\n```\n',
docs: 'This component should always be wrapped with ModalContent in the app.\n\nCorrect usage:\n```tsx\n<ModalContent isVerticallyCentered isHorizontallyCentered>\n <EmailVerificationSent email={email} />\n</ModalContent>\n```\n',
},
},
render: RenderWithModal,
render: RenderWithModalContent,
};
export default meta;
@@ -507,7 +507,7 @@ export const SettingsBillingSubscriptionInfo = ({
)}
</StyledSwitchButtonContainer>
<ConfirmationModal
modalId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
modalInstanceId={SWITCH_BILLING_INTERVAL_TO_YEARLY_MODAL_ID}
title={t`Change to Yearly?`}
subtitle={confirmationModalSwitchToYearlyMessage()}
onConfirmClick={switchInterval}
@@ -516,7 +516,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isSwitchingInterval}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_INTERVAL_TO_MONTHLY_MODAL_ID}
modalInstanceId={SWITCH_BILLING_INTERVAL_TO_MONTHLY_MODAL_ID}
title={t`Change to Monthly?`}
subtitle={confirmationModalSwitchToMonthlyMessage()}
onConfirmClick={switchInterval}
@@ -525,7 +525,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isSwitchingInterval}
/>
<ConfirmationModal
modalId={CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID}
modalInstanceId={CANCEL_SWITCH_BILLING_INTERVAL_MODAL_ID}
title={t`Cancel interval switching?`}
subtitle={confirmationModalCancelIntervalSwitchingMessage()}
onConfirmClick={cancelIntervalSwitching}
@@ -534,7 +534,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isCancellingIntervalSwitch}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_PLAN_TO_ENTERPRISE_MODAL_ID}
modalInstanceId={SWITCH_BILLING_PLAN_TO_ENTERPRISE_MODAL_ID}
title={t`Change to Organization Plan?`}
subtitle={confirmationModalSwitchToOrganizationMessage()}
onConfirmClick={switchPlan}
@@ -543,7 +543,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalId={SWITCH_BILLING_PLAN_TO_PRO_MODAL_ID}
modalInstanceId={SWITCH_BILLING_PLAN_TO_PRO_MODAL_ID}
title={t`Change to Pro Plan?`}
subtitle={confirmationModalSwitchToProMessage()}
onConfirmClick={switchPlan}
@@ -552,7 +552,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isSwitchingPlan}
/>
<ConfirmationModal
modalId={CANCEL_SWITCH_BILLING_PLAN_MODAL_ID}
modalInstanceId={CANCEL_SWITCH_BILLING_PLAN_MODAL_ID}
title={t`Cancel plan switching?`}
subtitle={confirmationModalCancelPlanSwitchingMessage()}
onConfirmClick={cancelPlanSwitching}
@@ -561,7 +561,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isCancellingPlanSwitch}
/>
<ConfirmationModal
modalId={END_TRIAL_PERIOD_MODAL_ID}
modalInstanceId={END_TRIAL_PERIOD_MODAL_ID}
title={t`Start Your Subscription`}
subtitle={t`We will activate your paid plan. Do you want to proceed?`}
onConfirmClick={endTrialPeriod}
@@ -570,7 +570,7 @@ export const SettingsBillingSubscriptionInfo = ({
loading={isEndTrialPeriodLoading}
/>
<ConfirmationModal
modalId={CANCEL_SWITCH_METERED_PRICE_MODAL_ID}
modalInstanceId={CANCEL_SWITCH_METERED_PRICE_MODAL_ID}
title={t`Cancel metered tier switching?`}
subtitle={t`You have scheduled a metered tier change. Do you want to cancel it?`}
onConfirmClick={cancelMeteredSwitching}
@@ -183,7 +183,7 @@ export const MeteredPriceSelector = ({
)}
</StyledRow>
<ConfirmationModal
modalId={confirmModalId}
modalInstanceId={confirmModalId}
title={isUpgrade() ? t`Confirm upgrade` : t`Confirm downgrade`}
subtitle={t`Confirm changing your current credit plan.`}
confirmButtonText={isUpgrade() ? t`Upgrade` : t`Downgrade`}
@@ -259,7 +259,7 @@ export const CurrentWorkspaceMemberFavorites = ({
{isModalOpened &&
createPortal(
<ConfirmationModal
modalId={modalId}
modalInstanceId={modalId}
title={
folder.favorites.length > 1
? t`Remove ${favoriteCount} favorites?`
@@ -317,7 +317,7 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({
{isModalOpened &&
createPortal(
<ConfirmationModal
modalId={modalId}
modalInstanceId={modalId}
title={
folder.navigationMenuItems.length > 1
? t`Remove ${navigationMenuItemCount} navigation menu items?`
@@ -278,7 +278,7 @@ export const RecordDetailRelationRecordsListItem = ({
</AnimatedEaseInOut>
{createPortal(
<ConfirmationModal
modalId={getDeleteRelationModalId(relationRecord.id)}
modalInstanceId={getDeleteRelationModalId(relationRecord.id)}
title={t`Delete Related ${relationObjectTypeName}`}
subtitle={
<Trans>
@@ -21,7 +21,7 @@ export const RecordGroupReorderConfirmationModal = ({
<>
{createPortal(
<ConfirmationModal
modalId={RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID}
modalInstanceId={RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID}
title={t`Group sorting`}
subtitle={t`Would you like to remove ${recordIndexRecordGroupSort} group sorting?`}
onConfirmClick={onConfirmClick}
@@ -26,7 +26,7 @@ export const RecordIndexRemoveSortingModal = () => {
return (
<ConfirmationModal
modalId={RECORD_INDEX_REMOVE_SORTING_MODAL_ID}
modalInstanceId={RECORD_INDEX_REMOVE_SORTING_MODAL_ID}
title={t`Remove sorting?`}
subtitle={t`This is required to enable manual row reordering.`}
onConfirmClick={handleRemoveClick}
@@ -137,7 +137,7 @@ export const SettingsAccountsRowDropdownMenu = ({
}
/>
<ConfirmationModal
modalId={deleteAccountModalId}
modalInstanceId={deleteAccountModalId}
title={t`Data deletion`}
subtitle={
<Trans>
@@ -2,14 +2,14 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { plural, t } from '@lingui/core/macro';
type SettingsAdminDeleteJobsConfirmationModalProps = {
modalId: string;
modalInstanceId: string;
jobCount: number;
onConfirm: () => void;
onClose?: () => void;
};
export const SettingsAdminDeleteJobsConfirmationModal = ({
modalId,
modalInstanceId,
jobCount,
onConfirm,
onClose,
@@ -26,7 +26,7 @@ export const SettingsAdminDeleteJobsConfirmationModal = ({
return (
<ConfirmationModal
modalId={modalId}
modalInstanceId={modalInstanceId}
title={title}
subtitle={subtitle}
onConfirmClick={onConfirm}
@@ -399,12 +399,12 @@ export const SettingsAdminQueueJobsTable = ({
)}
<SettingsAdminRetryJobsConfirmationModal
modalId={RETRY_MODAL_ID}
modalInstanceId={RETRY_MODAL_ID}
jobCount={selectedCount > 0 ? selectedCount : failedJobs.length}
onConfirm={confirmRetrySelected}
/>
<SettingsAdminDeleteJobsConfirmationModal
modalId={DELETE_MODAL_ID}
modalInstanceId={DELETE_MODAL_ID}
jobCount={selectedCount}
onConfirm={confirmDeleteSelected}
/>
@@ -2,14 +2,14 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { plural, t } from '@lingui/core/macro';
type SettingsAdminRetryJobsConfirmationModalProps = {
modalId: string;
modalInstanceId: string;
jobCount: number;
onConfirm: () => void;
onClose?: () => void;
};
export const SettingsAdminRetryJobsConfirmationModal = ({
modalId,
modalInstanceId,
jobCount,
onConfirm,
onClose,
@@ -26,7 +26,7 @@ export const SettingsAdminRetryJobsConfirmationModal = ({
return (
<ConfirmationModal
modalId={modalId}
modalInstanceId={modalInstanceId}
title={title}
subtitle={subtitle}
onConfirmClick={onConfirm}
@@ -139,7 +139,7 @@ export const ObjectSettings = ({
</StyledFormSection>
)}
<ConfirmationModal
modalId={DELETE_OBJECT_MODAL_ID}
modalInstanceId={DELETE_OBJECT_MODAL_ID}
title={t`Delete ${objectLabel} object?`}
subtitle={t`This will permanently delete the object and all its records. Type "yes" to confirm.`}
confirmButtonText={t`Delete`}
@@ -198,7 +198,7 @@ export const SettingsDevelopersWebhookForm = ({
<ConfirmationModal
confirmationPlaceholder={t`yes`}
confirmationValue={t`yes`}
modalId={DELETE_WEBHOOK_MODAL_ID}
modalInstanceId={DELETE_WEBHOOK_MODAL_ID}
title={t`Delete webhook`}
subtitle={
<Trans>
@@ -150,7 +150,7 @@ export const MemberPermissionsTab = ({
{pendingRole && (
<ConfirmationModal
modalId={CONFIRM_ROLE_CHANGE_MODAL_ID}
modalInstanceId={CONFIRM_ROLE_CHANGE_MODAL_ID}
title={t`Confirm role update`}
subtitle={t`Are you sure you want to update the role of this user from "${oldRoleLabel}" to "${newRoleLabel}"?`}
onConfirmClick={handleConfirmRoleChange}
@@ -86,7 +86,7 @@ export const DeleteAccount = () => {
<ConfirmationModal
confirmationValue={userEmail}
confirmationPlaceholder={userEmail ?? ''}
modalId={LEAVE_WORKSPACE_MODAL_ID}
modalInstanceId={LEAVE_WORKSPACE_MODAL_ID}
title={t`Leave workspace`}
subtitle={
<>
@@ -109,7 +109,7 @@ export const DeleteAccount = () => {
<ConfirmationModal
confirmationValue={userEmail}
confirmationPlaceholder={userEmail ?? ''}
modalId={DELETE_ACCOUNT_MODAL_ID}
modalInstanceId={DELETE_ACCOUNT_MODAL_ID}
title={t`Account Deletion`}
subtitle={
<>
@@ -43,7 +43,7 @@ export const DeleteWorkspace = () => {
/>
<ConfirmationModal
modalId={DELETE_WORKSPACE_MODAL_ID}
modalInstanceId={DELETE_WORKSPACE_MODAL_ID}
confirmationPlaceholder={userEmail}
confirmationValue={userEmail}
title={t`Workspace Deletion`}
@@ -25,7 +25,7 @@ export const SettingsRoleAssignmentConfirmationModal = ({
return (
<ConfirmationModal
modalId={ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID}
modalInstanceId={ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID}
title={title}
subtitle={
selectedRoleTarget.role ? (
@@ -26,7 +26,7 @@ export const SettingsRoleSettingsDeleteRoleConfirmationModal = ({
return (
<ConfirmationModal
modalId={ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID}
modalInstanceId={ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID}
title={t`Delete Role Permanently`}
subtitle={
<SettingsRoleSettingsDeleteRoleConfirmationModalSubtitle
@@ -98,7 +98,7 @@ export const DeleteTwoFactorAuthentication = () => {
<ConfirmationModal
confirmationValue={userEmail}
confirmationPlaceholder={userEmail ?? ''}
modalId={DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID}
modalInstanceId={DELETE_TWO_FACTOR_AUTHENTICATION_MODAL_ID}
title={t`2FA Method Reset`}
subtitle={
isTwoFactorAuthenticationEnforced ? (
@@ -2,14 +2,16 @@ import { styled } from '@linaria/react';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme-constants';
import { SpreadSheetImportModalCloseButton } from './SpreadSheetImportModalCloseButton';
const StyledModal = styled(Modal)`
const StyledInnerContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 600px;
min-width: 800px;
padding: 0;
position: relative;
@media (max-width: ${MOBILE_VIEWPORT}px) {
min-width: auto;
@@ -27,29 +29,32 @@ const StyledRtlLtr = styled.div`
type SpreadSheetImportModalWrapperProps = {
children: React.ReactNode;
modalId: string;
modalInstanceId: string;
onClose: () => void;
};
export const SpreadSheetImportModalWrapper = ({
modalId,
modalInstanceId,
children,
onClose,
}: SpreadSheetImportModalWrapperProps) => {
const { rtl } = useSpreadsheetImportInternal();
return (
<StyledModal
<ModalStatefulWrapper
size="extraLarge"
modalId={modalId}
padding="none"
modalInstanceId={modalInstanceId}
isClosable={true}
onClose={onClose}
shouldCloseModalOnClickOutsideOrEscape={false}
>
<StyledRtlLtr dir={rtl ? 'rtl' : 'ltr'}>
<SpreadSheetImportModalCloseButton onClose={onClose} />
{children}
</StyledRtlLtr>
</StyledModal>
<StyledInnerContainer>
<StyledRtlLtr dir={rtl ? 'rtl' : 'ltr'}>
<SpreadSheetImportModalCloseButton onClose={onClose} />
{children}
</StyledRtlLtr>
</StyledInnerContainer>
</ModalStatefulWrapper>
);
};
@@ -1,19 +1,16 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { t } from '@lingui/core/macro';
import { CircularProgressBar } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { ModalFooter } from 'twenty-ui/layout';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const StyledFooter = styled(Modal.Footer)`
const StyledFooter = styled(ModalFooter)`
border-top: 1px solid ${themeCssVariables.border.color.medium};
box-shadow: ${themeCssVariables.boxShadow.strong};
gap: 10px;
justify-content: space-between;
padding: ${themeCssVariables.spacing[4]};
height: auto;
`;
type StepNavigationButtonProps = {
@@ -34,7 +31,7 @@ export const StepNavigationButton = ({
isContinueDisabled = false,
}: StepNavigationButtonProps) => {
return (
<StyledFooter>
<StyledFooter autoHeight>
{!isUndefinedOrNull(onBack) && (
<MainButton
Icon={isLoading ? CircularProgressBar : undefined}
@@ -70,7 +70,7 @@ export const SpreadsheetImport = (props: SpreadsheetImportProps) => {
return (
<ReactSpreadsheetImportContextProvider values={mergedProps}>
<SpreadSheetImportModalWrapper
modalId={SPREADSHEET_IMPORT_MODAL_ID}
modalInstanceId={SPREADSHEET_IMPORT_MODAL_ID}
onClose={confirmOnClose}
>
<SpreadsheetImportStepperContainer />
@@ -3,19 +3,12 @@ import { StepNavigationButton } from '@/spreadsheet-import/components/StepNaviga
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { t } from '@lingui/core/macro';
import { Loader } from 'twenty-ui/feedback';
const StyledContent = styled(Modal.Content)`
align-items: center;
display: flex;
justify-content: center;
padding: 0px;
`;
const StyledHeader = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
@@ -50,11 +43,11 @@ export const ImportDataStep = ({
return (
<>
<StyledContent>
<ModalContent noPadding isVerticallyCentered isHorizontallyCentered>
<StyledHeader>{t`Importing Data ...`}</StyledHeader>
<StyledDescription>{t`${formattedCreatedRecordsProgress} out of ${formattedRecordsToImportCount} records imported.`}</StyledDescription>
<Loader />
</StyledContent>
</ModalContent>
<StepNavigationButton onBack={onClose} backTitle={t`Cancel`} />
</>
);
@@ -14,7 +14,7 @@ import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn';
import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn';
import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
import { ColumnGrid } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/ColumnGrid';
@@ -33,11 +33,6 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { Trans, useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContent = styled(Modal.Content)`
align-items: center;
padding: 0px;
`;
const StyledColumnsContainer = styled.div`
align-items: center;
display: flex;
@@ -276,7 +271,7 @@ export const MatchColumnsStep = ({
return (
<>
<StyledContent>
<ModalContent noPadding isVerticallyCentered>
<ScrollWrapper componentInstanceId="scroll-wrapper-modal-content">
<ColumnGrid
columns={columns}
@@ -304,7 +299,7 @@ export const MatchColumnsStep = ({
)}
/>
</ScrollWrapper>
</StyledContent>
</ModalContent>
<StepNavigationButton
onContinue={handleOnContinue}
isLoading={isLoading}
@@ -6,7 +6,7 @@ import { Heading } from '@/spreadsheet-import/components/Heading';
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
import { type ImportedRow } from '@/spreadsheet-import/types';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
@@ -104,7 +104,7 @@ export const SelectHeaderStep = ({
return (
<>
<Modal.Content>
<ModalContent>
<StyledHeading title={t`Select header row`} />
<StyledTableContainer>
<SelectHeaderTable
@@ -113,7 +113,7 @@ export const SelectHeaderStep = ({
setSelectedRowIndexes={setSelectedRowIndexes}
/>
</StyledTableContainer>
</Modal.Content>
</ModalContent>
<StepNavigationButton
onContinue={handleOnContinue}
onBack={onBack}
@@ -9,20 +9,12 @@ import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/Spre
import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords';
import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useLingui } from '@lingui/react/macro';
import { Radio, RadioGroup } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type WorkBook } from 'xlsx-ugnis';
const StyledContent = styled(Modal.Content)`
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
gap: ${themeCssVariables.spacing[8]};
`;
const StyledHeading = styled(Heading)`
display: flex;
`;
@@ -111,7 +103,7 @@ export const SelectSheetStep = ({
return (
<>
<StyledContent>
<ModalContent isVerticallyCentered isHorizontallyCentered gap={8}>
<StyledHeading title={t`Select the sheet to use`} />
<StyledRadioContainer>
<RadioGroup onValueChange={(value) => setValue(value)} value={value}>
@@ -124,7 +116,7 @@ export const SelectSheetStep = ({
))}
</RadioGroup>
</StyledRadioContainer>
</StyledContent>
</ModalContent>
<StepNavigationButton
onContinue={() => handleOnContinue(value)}
onBack={onBack}
@@ -1,10 +1,9 @@
import { styled } from '@linaria/react';
import { useCallback, useContext, useState } from 'react';
import { ThemeContext } from 'twenty-ui/theme';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { ImportDataStep } from '@/spreadsheet-import/steps/components/ImportDataStep';
import { type SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
@@ -16,12 +15,6 @@ import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep';
import { UploadStep } from './UploadStep/UploadStep';
import { ValidationStep } from './ValidationStep/ValidationStep';
const StyledProgressBarContainer = styled(Modal.Content)`
align-items: center;
display: flex;
justify-content: center;
`;
type SpreadsheetImportStepperProps = {
nextStep: () => void;
prevStep: () => void;
@@ -137,13 +130,13 @@ export const SpreadsheetImportStepper = ({
case SpreadsheetImportStepType.loading:
default:
return (
<StyledProgressBarContainer>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<CircularProgressBar
size={80}
barWidth={8}
barColor={theme.font.color.primary}
/>
</StyledProgressBarContainer>
</ModalContent>
);
}
};
@@ -1,5 +1,3 @@
import { styled } from '@linaria/react';
import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
@@ -7,24 +5,12 @@ import { StepBar } from '@/ui/navigation/step-bar/components/StepBar';
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ModalHeader } from 'twenty-ui/layout';
import { SpreadsheetImportStepper } from './SpreadsheetImportStepper';
const StyledHeader = styled(Modal.Header)`
background-color: ${themeCssVariables.background.secondary};
border-bottom: 1px solid ${themeCssVariables.border.color.medium};
padding: 0px ${themeCssVariables.spacing[30]};
height: 60px;
flex-shrink: 0;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${themeCssVariables.spacing[4]};
padding-right: ${themeCssVariables.spacing[4]};
}
`;
export const SpreadsheetImportStepperContainer = () => {
const { t } = useLingui();
@@ -50,7 +36,11 @@ export const SpreadsheetImportStepperContainer = () => {
return (
<>
<StyledHeader>
<ModalHeader
hasBorderBottom
paddingHorizontal={30}
backgroundColor={themeCssVariables.background.secondary}
>
{spreadsheetImportDialog.isStepBarVisible && (
<StepBar activeStep={activeStep}>
{steps.map((key) => (
@@ -62,7 +52,7 @@ export const SpreadsheetImportStepperContainer = () => {
))}
</StepBar>
)}
</StyledHeader>
</ModalHeader>
<SpreadsheetImportStepper nextStep={nextStep} prevStep={prevStep} />
</>
);
@@ -1,10 +1,8 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCallback, useState } from 'react';
import { type WorkBook } from 'xlsx-ugnis';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
@@ -14,10 +12,6 @@ import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords'
import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
import { DropZone } from './components/DropZone';
const StyledContent = styled(Modal.Content)`
padding: ${themeCssVariables.spacing[6]};
`;
type UploadStepProps = {
setUploadedFile: (file: File) => void;
setCurrentStepState: (data: any) => void;
@@ -118,8 +112,8 @@ export const UploadStep = ({
);
return (
<StyledContent>
<ModalContent contentPadding={6}>
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
</StyledContent>
</ModalContent>
);
};
@@ -12,7 +12,7 @@ import { type SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetC
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations';
import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -31,8 +31,10 @@ import { Button, Toggle } from 'twenty-ui/input';
import { generateColumns } from './components/columns';
import { type ImportedStructuredRowMetadata } from './types';
const StyledContent = styled(Modal.Content)`
padding: 0px;
const StyledContentWrapper = styled.div`
display: flex;
flex: 1 1 0%;
flex-direction: column;
position: relative;
`;
@@ -282,53 +284,55 @@ export const ValidationStep = ({
return (
<>
<StyledContent>
{filterByErrors && tableData.length === 0 ? (
<StyledNoRowsWithErrorsContainer>
<Trans>No rows with errors</Trans>
</StyledNoRowsWithErrorsContainer>
) : (
<StyledScrollContainer>
<SpreadsheetImportTable
headerRowHeight={32}
rowKeyGetter={rowKeyGetter}
rows={tableData}
onRowsChange={updateRow}
columns={columns}
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows as any} // TODO: replace 'any'
components={{
noRowsFallback: (
<StyledNoRowsContainer>
{filterByErrors
? t`No data containing errors`
: t`No data found`}
</StyledNoRowsContainer>
),
}}
<ModalContent noPadding>
<StyledContentWrapper>
{filterByErrors && tableData.length === 0 ? (
<StyledNoRowsWithErrorsContainer>
<Trans>No rows with errors</Trans>
</StyledNoRowsWithErrorsContainer>
) : (
<StyledScrollContainer>
<SpreadsheetImportTable
headerRowHeight={32}
rowKeyGetter={rowKeyGetter}
rows={tableData}
onRowsChange={updateRow}
columns={columns}
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows as any} // TODO: replace 'any'
components={{
noRowsFallback: (
<StyledNoRowsContainer>
{filterByErrors
? t`No data containing errors`
: t`No data found`}
</StyledNoRowsContainer>
),
}}
/>
</StyledScrollContainer>
)}
<StyledToolbar>
<StyledErrorToggle>
<Toggle
value={filterByErrors}
onChange={() => setFilterByErrors(!filterByErrors)}
toggleSize="small"
/>
<StyledErrorToggleDescription>
<Trans>Show only rows with errors</Trans>
</StyledErrorToggleDescription>
</StyledErrorToggle>
<StyledButton
Icon={IconTrash}
title={t`Remove`}
accent="default"
onClick={deleteSelectedRows}
disabled={selectedRows.size === 0}
/>
</StyledScrollContainer>
)}
<StyledToolbar>
<StyledErrorToggle>
<Toggle
value={filterByErrors}
onChange={() => setFilterByErrors(!filterByErrors)}
toggleSize="small"
/>
<StyledErrorToggleDescription>
<Trans>Show only rows with errors</Trans>
</StyledErrorToggleDescription>
</StyledErrorToggle>
<StyledButton
Icon={IconTrash}
title={t`Remove`}
accent="default"
onClick={deleteSelectedRows}
disabled={selectedRows.size === 0}
/>
</StyledToolbar>
</StyledContent>
</StyledToolbar>
</StyledContentWrapper>
</ModalContent>
<StepNavigationButton
onContinue={onContinue}
onBack={onBack}
@@ -66,7 +66,7 @@ export const Default = () => (
>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<SpreadSheetImportModalWrapper
modalId="match-columns-step"
modalInstanceId="match-columns-step"
onClose={() => null}
>
<MatchColumnsStep
@@ -43,7 +43,7 @@ export const Default = () => (
>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<SpreadSheetImportModalWrapper
modalId="select-header-step"
modalInstanceId="select-header-step"
onClose={() => null}
>
<SelectHeaderStep
@@ -43,7 +43,7 @@ export const Default = () => (
>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<SpreadSheetImportModalWrapper
modalId="select-sheet-step"
modalInstanceId="select-sheet-step"
onClose={() => null}
>
<SelectSheetStep
@@ -46,7 +46,10 @@ export const Default = () => (
value={{ instanceId: 'dialog-manager' }}
>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<SpreadSheetImportModalWrapper modalId="upload-step" onClose={() => null}>
<SpreadSheetImportModalWrapper
modalInstanceId="upload-step"
onClose={() => null}
>
<UploadStep
setUploadedFile={() => null}
setCurrentStepState={() => null}
@@ -46,7 +46,7 @@ export const Default = () => (
>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<SpreadSheetImportModalWrapper
modalId="validation-step"
modalInstanceId="validation-step"
onClose={() => null}
>
<ValidationStep
@@ -1,6 +1,6 @@
import { downloadFile } from '@/activities/files/utils/downloadFile';
import { filePreviewState } from '@/ui/field/display/states/filePreviewState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@@ -100,12 +100,12 @@ export const GlobalFilePreviewModal = (): JSX.Element | null => {
return (
<>
{createPortal(
<Modal
modalId={GLOBAL_FILE_PREVIEW_MODAL_ID}
<ModalStatefulWrapper
modalInstanceId={GLOBAL_FILE_PREVIEW_MODAL_ID}
size="large"
isClosable
onClose={handleClose}
ignoreContainer
renderInDocumentBody
>
<StyledModalHeader>
<StyledHeader>
@@ -141,7 +141,7 @@ export const GlobalFilePreviewModal = (): JSX.Element | null => {
</Suspense>
</StyledModalContent>
</ScrollWrapper>
</Modal>,
</ModalStatefulWrapper>,
document.body,
)}
</>
@@ -4,17 +4,22 @@ import { useDebouncedCallback } from 'use-debounce';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Modal, type ModalVariants } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { Button, type ButtonAccent } from 'twenty-ui/input';
import { Section, SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
Section,
SectionAlignment,
SectionFontColor,
type ModalOverlay,
} from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type ConfirmationModalProps = {
modalId: string;
modalInstanceId: string;
title: string;
loading?: boolean;
subtitle: ReactNode;
@@ -25,18 +30,11 @@ export type ConfirmationModalProps = {
confirmationValue?: string;
confirmButtonAccent?: ButtonAccent;
AdditionalButtons?: React.ReactNode;
modalVariant?: ModalVariants;
overlay?: ModalOverlay;
};
const StyledConfirmationModal = styled(Modal)`
border-radius: ${themeCssVariables.spacing[1]};
width: calc(400px - ${themeCssVariables.spacing[32]});
height: auto;
`;
export const StyledCenteredButton = styled(Button)`
box-sizing: border-box;
justify-content: center;
margin-top: ${themeCssVariables.spacing[2]};
`;
@@ -62,7 +60,7 @@ export const StyledConfirmationButton = styled(StyledCenteredButton)`
const defaultConfirmButtonText = msg`Confirm`;
export const ConfirmationModal = ({
modalId,
modalInstanceId,
title,
loading,
subtitle,
@@ -73,7 +71,7 @@ export const ConfirmationModal = ({
confirmationPlaceholder,
confirmButtonAccent = 'danger',
AdditionalButtons,
modalVariant = 'primary',
overlay = 'dark',
}: ConfirmationModalProps) => {
const { i18n, t } = useLingui();
const translatedConfirmButtonText =
@@ -97,12 +95,12 @@ export const ConfirmationModal = ({
const { closeModal } = useModal();
const handleConfirmClick = () => {
closeModal(modalId);
closeModal(modalInstanceId);
onConfirmClick();
};
const handleCancelClick = () => {
closeModal(modalId);
closeModal(modalInstanceId);
onClose?.();
};
@@ -113,17 +111,20 @@ export const ConfirmationModal = ({
};
return (
<StyledConfirmationModal
modalId={modalId}
<ModalStatefulWrapper
modalInstanceId={modalInstanceId}
onClose={() => {
onClose?.();
}}
onEnter={handleEnter}
isClosable={true}
padding="large"
modalVariant={modalVariant}
overlay={overlay}
dataGloballyPreventClickOutside
ignoreContainer
renderInDocumentBody
smallBorderRadius
narrowWidth
autoHeight
>
<StyledCenteredTitle>
<H1Title title={title} fontColor={H1TitleFontColor.Primary} />
@@ -153,6 +154,7 @@ export const ConfirmationModal = ({
variant="secondary"
title={t`Cancel`}
fullWidth
justify="center"
dataTestId="confirmation-modal-cancel-button"
/>
@@ -165,8 +167,9 @@ export const ConfirmationModal = ({
title={translatedConfirmButtonText}
disabled={!isValidValue || loading}
fullWidth
justify="center"
dataTestId="confirmation-modal-confirm-button"
/>
</StyledConfirmationModal>
</ModalStatefulWrapper>
);
};
@@ -1,322 +0,0 @@
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect';
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
import { useModalContainer } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { AnimatePresence, motion } from 'framer-motion';
import React, { useContext, useRef } from 'react';
import { createPortal } from 'react-dom';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ThemeContext } from 'twenty-ui/theme';
const StyledModalDivBase = styled.div<{
size?: ModalSize;
padding?: ModalPadding;
isMobile: boolean;
modalVariant: ModalVariants;
}>`
display: flex;
flex-direction: column;
box-shadow: ${({ modalVariant }) =>
modalVariant === 'primary'
? themeCssVariables.boxShadow.superHeavy
: modalVariant === 'transparent'
? 'none'
: themeCssVariables.boxShadow.strong};
background: ${({ modalVariant }) =>
modalVariant === 'transparent'
? 'transparent'
: themeCssVariables.background.primary};
color: ${themeCssVariables.font.color.primary};
border-radius: ${({ isMobile, modalVariant }) => {
if (isMobile || modalVariant === 'transparent') return `0`;
return themeCssVariables.border.radius.md;
}};
overflow-x: hidden;
overflow-y: auto;
z-index: ${RootStackingContextZIndices.RootModal}; // should be higher than Backdrop's z-index
width: ${({ isMobile, size }) => {
if (isMobile)
return themeCssVariables.modal.size.fullscreen.width ?? 'auto';
switch (size) {
case 'small':
return themeCssVariables.modal.size.sm.width ?? 'auto';
case 'medium':
return themeCssVariables.modal.size.md.width ?? 'auto';
case 'large':
return themeCssVariables.modal.size.lg.width ?? 'auto';
case 'extraLarge':
return themeCssVariables.modal.size.xl.width ?? 'auto';
default:
return 'auto';
}
}};
padding: ${({ padding }) => {
switch (padding) {
case 'none':
return themeCssVariables.spacing[0];
case 'small':
return themeCssVariables.spacing[2];
case 'medium':
return themeCssVariables.spacing[4];
case 'large':
return themeCssVariables.spacing[6];
default:
return 'auto';
}
}};
height: ${({ isMobile, size }) => {
if (isMobile)
return themeCssVariables.modal.size.fullscreen.height ?? 'auto';
switch (size) {
case 'extraLarge':
return themeCssVariables.modal.size.xl.height ?? 'auto';
default:
return 'auto';
}
}};
max-height: ${({ isMobile }) => (isMobile ? 'none' : '90dvh')};
`;
const StyledModalDiv = motion.create(StyledModalDivBase);
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${themeCssVariables.spacing[5]};
`;
const StyledContent = styled.div<{
isVerticalCentered?: boolean;
isHorizontalCentered?: boolean;
}>`
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
padding: ${themeCssVariables.spacing[10]};
align-items: ${({ isVerticalCentered }) =>
isVerticalCentered ? 'center' : 'stretch'};
justify-content: ${({ isHorizontalCentered }) =>
isHorizontalCentered ? 'center' : 'flex-start'};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${themeCssVariables.spacing[5]};
`;
const StyledBackDropBase = styled.div<{
modalVariant: ModalVariants;
isInContainer?: boolean;
}>`
align-items: center;
background: ${({ modalVariant, isInContainer }) =>
isInContainer
? themeCssVariables.background.overlayTertiary
: modalVariant === 'primary' || modalVariant === 'transparent'
? themeCssVariables.background.overlayPrimary
: modalVariant === 'secondary'
? themeCssVariables.background.overlaySecondary
: themeCssVariables.background.overlayTertiary};
display: flex;
height: 100%;
justify-content: center;
left: 0;
pointer-events: auto;
position: ${({ isInContainer }) => (isInContainer ? 'absolute' : 'fixed')};
top: 0;
width: 100%;
z-index: ${RootStackingContextZIndices.RootModalBackDrop};
user-select: none;
`;
const StyledBackDrop = motion.create(StyledBackDropBase);
type ModalHeaderProps = React.PropsWithChildren & {
className?: string;
};
const ModalHeader = ({ children, className }: ModalHeaderProps) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalContentProps = React.PropsWithChildren & {
className?: string;
isVerticalCentered?: boolean;
isHorizontalCentered?: boolean;
};
const ModalContent = ({
children,
className,
isVerticalCentered,
isHorizontalCentered,
}: ModalContentProps) => (
<StyledContent
className={className}
isVerticalCentered={isVerticalCentered}
isHorizontalCentered={isHorizontalCentered}
>
{children}
</StyledContent>
);
type ModalFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalFooter = ({ children, className }: ModalFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
export type ModalVariants =
| 'primary'
| 'secondary'
| 'tertiary'
| 'transparent';
export type ModalProps = React.PropsWithChildren & {
modalId: string;
size?: ModalSize;
padding?: ModalPadding;
className?: string;
onEnter?: () => void;
modalVariant?: ModalVariants;
dataGloballyPreventClickOutside?: boolean;
shouldCloseModalOnClickOutsideOrEscape?: boolean;
ignoreContainer?: boolean;
} & (
| { isClosable: true; onClose?: () => void }
| { isClosable?: false; onClose?: never }
);
const modalAnimation = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
modalId,
children,
size = 'medium',
padding = 'medium',
className,
onEnter,
isClosable = false,
onClose,
modalVariant = 'primary',
dataGloballyPreventClickOutside = false,
shouldCloseModalOnClickOutsideOrEscape = true,
ignoreContainer = false,
}: ModalProps) => {
const isMobile = useIsMobile();
const modalRef = useRef<HTMLDivElement>(null);
const { container } = useModalContainer();
const effectiveContainer = ignoreContainer
? isDefined(document)
? document.body
: null
: container;
const isInContainer = isDefined(container) && !ignoreContainer;
const { theme } = useContext(ThemeContext);
const stopEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalId,
);
const { closeModal } = useModal();
const handleClose = () => {
onClose?.();
if (shouldCloseModalOnClickOutsideOrEscape) closeModal(modalId);
};
const modalContent = (
<AnimatePresence mode="wait">
{isModalOpened && (
<ModalComponentInstanceContext.Provider
value={{
instanceId: modalId,
}}
>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
}}
>
<ModalHotkeysAndClickOutsideEffect
modalId={modalId}
modalRef={modalRef}
onEnter={onEnter}
isClosable={isClosable}
onClose={handleClose}
/>
<StyledBackDrop
data-testid="modal-backdrop"
data-click-outside-id={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
onMouseDown={stopEventPropagation}
modalVariant={modalVariant}
isInContainer={isInContainer}
>
<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}
data-globally-prevent-click-outside={
dataGloballyPreventClickOutside
}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
</ClickOutsideListenerContext.Provider>
</ModalComponentInstanceContext.Provider>
)}
</AnimatePresence>
);
if (isDefined(effectiveContainer)) {
return createPortal(modalContent, effectiveContainer);
}
return modalContent;
};
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;
Modal.Backdrop = StyledBackDrop;
@@ -9,7 +9,7 @@ type ModalHotkeysAndClickOutsideEffectProps = {
onEnter?: () => void;
isClosable?: boolean;
onClose?: () => void;
modalId: string;
modalInstanceId: string;
};
export const ModalHotkeysAndClickOutsideEffect = ({
@@ -17,14 +17,14 @@ export const ModalHotkeysAndClickOutsideEffect = ({
onEnter,
isClosable = false,
onClose,
modalId,
modalInstanceId,
}: ModalHotkeysAndClickOutsideEffectProps) => {
useHotkeysOnFocusedElement({
keys: [Key.Enter],
callback: () => {
onEnter?.();
},
focusId: modalId,
focusId: modalInstanceId,
dependencies: [onEnter],
});
@@ -35,7 +35,7 @@ export const ModalHotkeysAndClickOutsideEffect = ({
onClose();
}
},
focusId: modalId,
focusId: modalInstanceId,
dependencies: [isClosable, onClose],
});
@@ -45,7 +45,7 @@ export const ModalHotkeysAndClickOutsideEffect = ({
MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
DIALOG_CLICK_OUTSIDE_ID,
],
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalId}`,
listenerId: `MODAL_CLICK_OUTSIDE_LISTENER_ID_${modalInstanceId}`,
callback: () => {
if (isClosable && onClose !== undefined) {
onClose();
@@ -0,0 +1,96 @@
import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
import { MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID } from '@/ui/layout/modal/constants/ModalClickOutsideListenerExcludedClassName';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
import { useModalContainer } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { type ModalStatefulWrapperProps } from '@/ui/layout/modal/types/ModalStatefulWrapperProps';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Modal } from 'twenty-ui/layout';
export const ModalStatefulWrapper = ({
modalInstanceId,
children,
size = 'medium',
padding = 'medium',
onEnter,
isClosable = false,
onClose,
overlay = 'dark',
dataGloballyPreventClickOutside = false,
shouldCloseModalOnClickOutsideOrEscape = true,
renderInDocumentBody = false,
gap,
smallBorderRadius,
narrowWidth,
autoHeight,
}: ModalStatefulWrapperProps) => {
const isMobile = useIsMobile();
const modalRef = useRef<HTMLDivElement>(null);
const { container } = useModalContainer();
const effectiveContainer = renderInDocumentBody ? document.body : container;
const isInContainer = isDefined(container) && !renderInDocumentBody;
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalInstanceId,
);
const { closeModal } = useModal();
const handleClose = () => {
onClose?.();
if (shouldCloseModalOnClickOutsideOrEscape) {
closeModal(modalInstanceId);
}
};
return (
<ModalComponentInstanceContext.Provider
value={{ instanceId: modalInstanceId }}
>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MODAL_CLICK_OUTSIDE_LISTENER_EXCLUDED_ID,
}}
>
{isModalOpened && (
<ModalHotkeysAndClickOutsideEffect
modalInstanceId={modalInstanceId}
modalRef={modalRef}
onEnter={onEnter}
isClosable={isClosable}
onClose={handleClose}
/>
)}
<Modal
isOpen={isModalOpened}
size={size}
padding={padding}
overlay={isInContainer ? 'light' : overlay}
isMobile={isMobile}
isInContainer={isInContainer}
container={effectiveContainer}
gap={gap}
smallBorderRadius={smallBorderRadius}
narrowWidth={narrowWidth}
autoHeight={autoHeight}
modalZIndex={RootStackingContextZIndices.RootModal}
backdropZIndex={RootStackingContextZIndices.RootModalBackDrop}
backdropClickOutsideId={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
preventClickOutside={dataGloballyPreventClickOutside}
modalRef={modalRef}
>
{children}
</Modal>
</ClickOutsideListenerContext.Provider>
</ModalComponentInstanceContext.Provider>
);
};
@@ -54,7 +54,7 @@ const confirmMock = fn();
export const Default: Story = {
args: {
modalId: 'confirmation-modal',
modalInstanceId: 'confirmation-modal',
title: 'Pariatur labore.',
subtitle: 'Velit dolore aliquip laborum occaecat fugiat.',
confirmButtonText: 'Delete',
@@ -72,7 +72,7 @@ export const InputConfirmation: Story = {
export const CloseOnEscape: Story = {
args: {
modalId: 'confirmation-modal',
modalInstanceId: 'confirmation-modal',
title: 'Escape Key Test',
subtitle: 'This modal should close when pressing the Escape key.',
confirmButtonText: 'Confirm',
@@ -95,7 +95,7 @@ export const CloseOnEscape: Story = {
export const CloseOnClickOutside: Story = {
args: {
modalId: 'confirmation-modal',
modalInstanceId: 'confirmation-modal',
title: 'Click Outside Test',
subtitle: 'This modal should close when clicking outside of it.',
confirmButtonText: 'Confirm',
@@ -121,7 +121,7 @@ export const CloseOnClickOutside: Story = {
export const ConfirmWithEnterKey: Story = {
args: {
modalId: 'confirmation-modal',
modalInstanceId: 'confirmation-modal',
title: 'Enter Key Test',
subtitle: 'This modal should confirm when pressing the Enter key.',
confirmButtonText: 'Confirm',
@@ -142,7 +142,7 @@ export const ConfirmWithEnterKey: Story = {
export const CancelButtonClick: Story = {
args: {
modalId: 'confirmation-modal',
modalInstanceId: 'confirmation-modal',
title: 'Cancel Button Test',
subtitle: 'Clicking the cancel button should close the modal',
confirmButtonText: 'Confirm',
@@ -166,7 +166,7 @@ export const CancelButtonClick: Story = {
export const ConfirmButtonClick: Story = {
args: {
modalId: 'confirmation-modal',
modalInstanceId: 'confirmation-modal',
title: 'Confirm Button Test',
subtitle: 'Clicking the confirm button should trigger the confirm action',
confirmButtonText: 'Confirm',
@@ -5,11 +5,12 @@ import {
} from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ModalContent, ModalFooter, ModalHeader } from 'twenty-ui/layout';
import { ComponentDecorator } from 'twenty-ui/testing';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { sleep } from '~/utils/sleep';
@@ -37,9 +38,9 @@ const JotaiInitDecorator: Decorator = (Story) => {
return <Story />;
};
const meta: Meta<typeof Modal> = {
title: 'UI/Layout/Modal/Modal',
component: Modal,
const meta: Meta<typeof ModalStatefulWrapper> = {
title: 'UI/Layout/Modal/ModalStatefulWrapper',
component: ModalStatefulWrapper,
decorators: [JotaiInitDecorator, RootDecorator, ComponentDecorator],
parameters: {
disableHotkeyInitialization: true,
@@ -47,26 +48,26 @@ const meta: Meta<typeof Modal> = {
};
export default meta;
type Story = StoryObj<typeof Modal>;
type Story = StoryObj<typeof ModalStatefulWrapper>;
const closeMock = fn();
export const Default: Story = {
args: {
modalId: 'modal-id',
modalInstanceId: 'modal-id',
size: 'medium',
padding: 'medium',
children: (
<>
<Modal.Header>Stay in touch</Modal.Header>
<Modal.Content>
<ModalHeader>Stay in touch</ModalHeader>
<ModalContent>
This is a dummy newletter form so don't bother trying to test it. Not
that I expect you to, anyways. :)
</Modal.Content>
<Modal.Footer>
</ModalContent>
<ModalFooter>
By using Twenty, you're opting for the finest CRM experience you'll
ever encounter.
</Modal.Footer>
</ModalFooter>
</>
),
},
@@ -74,17 +75,17 @@ export const Default: Story = {
export const CloseClosableModalOnClickOutside: Story = {
args: {
modalId: 'modal-id',
modalInstanceId: 'modal-id',
size: 'medium',
padding: 'medium',
isClosable: true,
onClose: closeMock,
children: (
<>
<Modal.Header>Click Outside Test</Modal.Header>
<Modal.Content>
<ModalHeader>Click Outside Test</ModalHeader>
<ModalContent>
This modal should close when clicking outside of it.
</Modal.Content>
</ModalContent>
</>
),
},
@@ -94,7 +95,6 @@ export const CloseClosableModalOnClickOutside: Story = {
await canvas.findByText('Click Outside Test');
const backdrop = await canvas.findByTestId('modal-backdrop');
// We need to wait for the outside click listener to be registered
await sleep(100);
await userEvent.click(backdrop);
@@ -106,17 +106,17 @@ export const CloseClosableModalOnClickOutside: Story = {
export const CloseClosableModalOnEscape: Story = {
args: {
modalId: 'modal-id',
modalInstanceId: 'modal-id',
size: 'medium',
padding: 'medium',
isClosable: true,
onClose: closeMock,
children: (
<>
<Modal.Header>Escape Key Test</Modal.Header>
<Modal.Content>
<ModalHeader>Escape Key Test</ModalHeader>
<ModalContent>
This modal should close when pressing the Escape key.
</Modal.Content>
</ModalContent>
</>
),
},
@@ -14,9 +14,11 @@ export const useModal = () => {
const store = useStore();
const closeModal = useCallback(
(modalId: string) => {
(modalInstanceId: string) => {
const isModalOpen = store.get(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
isModalOpenedComponentState.atomFamily({
instanceId: modalInstanceId,
}),
);
if (!isModalOpen) {
@@ -24,11 +26,13 @@ export const useModal = () => {
}
removeFocusItemFromFocusStackById({
focusId: modalId,
focusId: modalInstanceId,
});
store.set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
isModalOpenedComponentState.atomFamily({
instanceId: modalInstanceId,
}),
false,
);
},
@@ -36,9 +40,11 @@ export const useModal = () => {
);
const openModal = useCallback(
(modalId: string) => {
(modalInstanceId: string) => {
const isModalOpened = store.get(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
isModalOpenedComponentState.atomFamily({
instanceId: modalInstanceId,
}),
);
if (isModalOpened) {
@@ -46,15 +52,17 @@ export const useModal = () => {
}
store.set(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
isModalOpenedComponentState.atomFamily({
instanceId: modalInstanceId,
}),
true,
);
pushFocusItemToFocusStack({
focusId: modalId,
focusId: modalInstanceId,
component: {
type: FocusComponentType.MODAL,
instanceId: modalId,
instanceId: modalInstanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: false,
@@ -66,15 +74,17 @@ export const useModal = () => {
);
const toggleModal = useCallback(
(modalId: string) => {
(modalInstanceId: string) => {
const isModalOpen = store.get(
isModalOpenedComponentState.atomFamily({ instanceId: modalId }),
isModalOpenedComponentState.atomFamily({
instanceId: modalInstanceId,
}),
);
if (isModalOpen) {
closeModal(modalId);
closeModal(modalInstanceId);
} else {
openModal(modalId);
openModal(modalInstanceId);
}
},
[store, closeModal, openModal],
@@ -0,0 +1,23 @@
import type React from 'react';
import { type ModalProps } from 'twenty-ui/layout';
export type ModalStatefulWrapperProps = Pick<
ModalProps,
| 'size'
| 'padding'
| 'overlay'
| 'gap'
| 'smallBorderRadius'
| 'narrowWidth'
| 'autoHeight'
> &
React.PropsWithChildren & {
modalInstanceId: string;
onEnter?: () => void;
dataGloballyPreventClickOutside?: boolean;
shouldCloseModalOnClickOutsideOrEscape?: boolean;
renderInDocumentBody?: boolean;
} & (
| { isClosable: true; onClose?: () => void }
| { isClosable?: false; onClose?: never }
);
@@ -42,7 +42,7 @@ export const OverrideWorkflowDraftConfirmationModal = ({
return (
<>
<ConfirmationModal
modalId={OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID}
modalInstanceId={OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID}
title={t`A draft already exists`}
subtitle={t`A draft already exists for this workflow. Are you sure you want to erase it?`}
onConfirmClick={handleOverrideDraft}
@@ -11,7 +11,7 @@ import { useCaptcha } from '@/client-config/hooks/useCaptcha';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { ApolloError } from '@apollo/client';
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -206,7 +206,7 @@ export const PasswordReset = () => {
return (
isTokenValid && (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<StyledMainContainer>
<AnimatedEaseIn>
<Logo
@@ -291,7 +291,7 @@ export const PasswordReset = () => {
)}
</StyledContentContainer>
</StyledMainContainer>
</Modal.Content>
</ModalContent>
)
);
};
@@ -29,7 +29,7 @@ import { SignInUpTwoFactorAuthenticationProvision } from '@/auth/sign-in-up/comp
import { SignInUpTOTPVerification } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification';
import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useLingui } from '@lingui/react/macro';
import { useSearchParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
@@ -61,7 +61,7 @@ const StandardContent = ({
onClickOnLogo: () => void;
}) => {
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<AnimatedEaseIn>
<Logo
secondaryLogo={workspacePublicData?.logo}
@@ -77,7 +77,7 @@ const StandardContent = ({
SignInUpStep.TwoFactorAuthenticationVerification,
SignInUpStep.WorkspaceSelection,
].includes(signInUpStep) && <FooterNote />}
</Modal.Content>
</ModalContent>
);
};
@@ -205,9 +205,9 @@ export const SignInUp = () => {
if (signInUpStep === SignInUpStep.EmailVerification) {
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={searchParams.get('email')} />
</Modal.Content>
</ModalContent>
);
}
@@ -1,12 +1,11 @@
import Cal from '@calcom/embed-react';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { Link } from 'react-router-dom';
import { currentUserState } from '@/auth/states/currentUserState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent, ModalFooter } from 'twenty-ui/layout';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -14,24 +13,12 @@ import { AppPath } from 'twenty-shared/types';
import { IconChevronLeft, IconChevronRightPipe } from 'twenty-ui/display';
import { LightButton } from 'twenty-ui/input';
import { ThemeContext } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
import {
OnboardingStatus,
useSkipBookOnboardingStepMutation,
} from '~/generated-metadata/graphql';
const StyledModalFooter = styled(Modal.Footer)`
height: auto;
justify-content: center;
padding: ${themeCssVariables.spacing[3]};
`;
const StyledModalContent = styled(Modal.Content)`
overflow: hidden;
padding: 0;
`;
export const BookCall = () => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
@@ -51,7 +38,12 @@ export const BookCall = () => {
return (
<>
<StyledModalContent isHorizontalCentered isVerticalCentered>
<ModalContent
noPadding
overflowHidden
isHorizontallyCentered
isVerticallyCentered
>
<ScrollWrapper
componentInstanceId="scroll-wrapper-modal-content"
autoHeight={!isMobile}
@@ -66,8 +58,8 @@ export const BookCall = () => {
}}
/>
</ScrollWrapper>
</StyledModalContent>
<StyledModalFooter>
</ModalContent>
<ModalFooter autoHeight centered smallPadding>
{isPlanRequired ? (
<Link to={AppPath.PlanRequired}>
<LightButton Icon={IconChevronLeft} title={t`Back`} />
@@ -79,7 +71,7 @@ export const BookCall = () => {
onClick={handleCompleteOnboarding}
/>
)}
</StyledModalFooter>
</ModalFooter>
</>
);
};
@@ -1,7 +1,7 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Link } from 'react-router-dom';
@@ -17,10 +17,6 @@ const StyledCoverImage = styled.img`
width: 320px;
`;
const StyledModalContent = styled(Modal.Content)`
gap: ${themeCssVariables.spacing[8]};
`;
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
@@ -51,7 +47,7 @@ export const BookCallDecision = () => {
};
return (
<StyledModalContent isVerticalCentered isHorizontalCentered>
<ModalContent gap={8} isVerticallyCentered isHorizontallyCentered>
<StyledTitleContainer>
<Title noMarginTop>
<Trans>Book your onboarding</Trans>
@@ -70,6 +66,6 @@ export const BookCallDecision = () => {
</StyledLink>
<LightButton title={t`Finish`} onClick={handleFinish} />
</StyledButtonContainer>
</StyledModalContent>
</ModalContent>
);
};
@@ -1,4 +1,4 @@
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { ChooseYourPlanContent } from '~/pages/onboarding/internal/ChooseYourPlanContent';
@@ -14,12 +14,12 @@ export const ChooseYourPlan = () => {
const { isPlansLoaded } = usePlans();
const billing = useAtomStateValue(billingState);
return (
<Modal.Content isVerticalCentered>
<ModalContent isVerticallyCentered>
{isDefined(billing) && isPlansLoaded ? (
<ChooseYourPlanContent billing={billing} />
) : (
<StyledChooseYourPlanPlaceholder />
)}
</Modal.Content>
</ModalContent>
);
};
@@ -18,7 +18,7 @@ import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/comp
import { PageFocusId } from '@/types/PageFocusId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { ApolloError } from '@apollo/client';
import { i18n } from '@lingui/core';
@@ -172,7 +172,7 @@ export const CreateProfile = () => {
});
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<Title noMarginTop>
<Trans>Create profile</Trans>
</Title>
@@ -251,6 +251,6 @@ export const CreateProfile = () => {
fullWidth
/>
</StyledButtonContainer>
</Modal.Content>
</ModalContent>
);
};
@@ -14,7 +14,7 @@ import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboard
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -152,7 +152,7 @@ export const CreateWorkspace = () => {
};
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
{pendingCreationLoaderStep !== PendingCreationLoaderStep.None && (
<>
<Logo
@@ -241,6 +241,6 @@ export const CreateWorkspace = () => {
</StyledButtonContainer>
</>
)}
</Modal.Content>
</ModalContent>
);
};
@@ -6,7 +6,7 @@ import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboard
import { PageFocusId } from '@/types/PageFocusId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -169,7 +169,7 @@ export const InviteTeam = () => {
});
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<Title>
<Trans>Invite your team</Trans>
</Title>
@@ -229,6 +229,6 @@ export const InviteTeam = () => {
<Trans>Skip</Trans>
</ClickToActionLink>
</StyledActionSkipLinkContainer>
</Modal.Content>
</ModalContent>
);
};
@@ -2,7 +2,7 @@ import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingModalCircularIcon } from '@/onboarding/components/OnboardingModalCircularIcon';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
@@ -13,15 +13,10 @@ import { isDefined } from 'twenty-shared/utils';
import { IconCheck } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AnimatedEaseIn } from 'twenty-ui/utilities';
import { useGetCurrentUserLazyQuery } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const StyledModalContent = styled(Modal.Content)`
gap: ${themeCssVariables.spacing[8]};
`;
const StyledTitleContainer = styled.div`
display: flex;
flex-direction: column;
@@ -67,7 +62,7 @@ export const PaymentSuccess = () => {
};
return (
<StyledModalContent isVerticalCentered isHorizontalCentered>
<ModalContent gap={8} isVerticallyCentered isHorizontallyCentered>
<AnimatedEaseIn>
<OnboardingModalCircularIcon Icon={IconCheck} />
</AnimatedEaseIn>
@@ -82,6 +77,6 @@ export const PaymentSuccess = () => {
Icon={() => (isLoading ? <Loader /> : null)}
disabled={isLoading}
/>
</StyledModalContent>
</ModalContent>
);
};
@@ -15,7 +15,7 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { PageFocusId } from '@/types/PageFocusId';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalContent } from 'twenty-ui/layout';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
@@ -116,7 +116,7 @@ export const SyncEmails = () => {
});
return (
<Modal.Content isVerticalCentered isHorizontalCentered>
<ModalContent isVerticallyCentered isHorizontallyCentered>
<Title noMarginTop>{t`Emails and Calendar`}</Title>
<SubTitle>
{t`Sync your Emails and Calendar with Twenty. Choose your privacy settings.`}
@@ -175,6 +175,6 @@ export const SyncEmails = () => {
{t`Continue without sync`}
</ClickToActionLink>
</StyledActionLinkContainer>
</Modal.Content>
</ModalContent>
);
};
@@ -195,7 +195,7 @@ export const SettingsAdminConfigVariableDetails = () => {
</SubMenuTopBarContainer>
<ConfirmationModal
modalId={RESET_VARIABLE_MODAL_ID}
modalInstanceId={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}
@@ -583,7 +583,7 @@ export const SettingsSkillForm = ({ mode }: { mode: 'create' | 'edit' }) => {
</SettingsPageContainer>
<ConfirmationModal
modalId={DELETE_SKILL_MODAL_ID}
modalInstanceId={DELETE_SKILL_MODAL_ID}
title={t`Delete Skill`}
subtitle={t`Are you sure you want to delete this skill? This action cannot be undone.`}
onConfirmClick={handleDelete}
@@ -46,7 +46,7 @@ export const SettingsAgentDeleteConfirmationModal = ({
<ConfirmationModal
confirmationValue={agentName}
confirmationPlaceholder={agentName}
modalId={DELETE_AGENT_MODAL_ID}
modalInstanceId={DELETE_AGENT_MODAL_ID}
title={t`Delete Agent`}
subtitle={
<Trans>
@@ -198,7 +198,7 @@ export const SettingsAgentEvalsTab = ({
</Section>
<ConfirmationModal
modalId={DELETE_EVAL_INPUT_MODAL_ID}
modalInstanceId={DELETE_EVAL_INPUT_MODAL_ID}
title={t`Delete Evaluation Input`}
subtitle={t`Are you sure you want to delete this evaluation input?`}
onConfirmClick={handleDeleteInput}
@@ -592,7 +592,7 @@ export const SettingsApplicationRegistrationDetails = () => {
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={ROTATE_SECRET_MODAL_ID}
modalInstanceId={ROTATE_SECRET_MODAL_ID}
title={t`Rotate client secret`}
subtitle={
<Trans>
@@ -609,7 +609,7 @@ export const SettingsApplicationRegistrationDetails = () => {
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={DELETE_REGISTRATION_MODAL_ID}
modalInstanceId={DELETE_REGISTRATION_MODAL_ID}
title={t`Delete app`}
subtitle={
<Trans>
@@ -110,7 +110,7 @@ export const SettingsApplicationDetailAboutTab = ({
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={UNINSTALL_APPLICATION_MODAL_ID}
modalInstanceId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
@@ -429,7 +429,7 @@ export const SettingsObjectFieldEdit = () => {
</FormProvider>
{fieldMetadataItem?.isCustom && (
<ConfirmationModal
modalId={DELETE_FIELD_MODAL_ID}
modalInstanceId={DELETE_FIELD_MODAL_ID}
title={t`Delete ${fieldLabel} field?`}
subtitle={t`This will permanently delete the field and all its data from ${objectLabel}. Type "yes" to confirm.`}
confirmButtonText={t`Delete`}
@@ -324,7 +324,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={DELETE_API_KEY_MODAL_ID}
modalInstanceId={DELETE_API_KEY_MODAL_ID}
title={t`Delete API key`}
subtitle={
<Trans>
@@ -340,7 +340,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={REGENERATE_API_KEY_MODAL_ID}
modalInstanceId={REGENERATE_API_KEY_MODAL_ID}
title={t`Regenerate an API key`}
subtitle={
<Trans>
@@ -237,7 +237,7 @@ export const SettingsDomain = () => {
</FormProvider>
</form>
<ConfirmationModal
modalId={SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID}
modalInstanceId={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.`}
onConfirmClick={() => {
@@ -219,7 +219,7 @@ export const SettingsWorkspaceMember = () => {
</SettingsPageContainer>
<ConfirmationModal
modalId={DELETE_MEMBER_MODAL_ID}
modalInstanceId={DELETE_MEMBER_MODAL_ID}
title={t`Remove member from workspace`}
subtitle={t`This action cannot be undone. This will permanently remove this member from this workspace and remove them from all their assignments.`}
onConfirmClick={handleDeleteMember}
+6 -5
View File
@@ -15,6 +15,8 @@ import {
import checker from 'vite-plugin-checker';
import svgr from 'vite-plugin-svgr';
import tsconfigPaths from 'vite-tsconfig-paths';
import { createWywProfilingPlugin } from 'twenty-shared/vite';
type Checkers = Parameters<typeof checker>[0];
export default defineConfig(({ command, mode }) => {
@@ -107,9 +109,9 @@ export default defineConfig(({ command, mode }) => {
configPath: path.resolve(__dirname, './lingui.config.ts'),
}),
checker(checkers),
{
...wyw({
include: ['**/*.{ts,tsx}'],
createWywProfilingPlugin(
wyw({
include: [path.resolve(__dirname, 'src') + '/**/*.{ts,tsx}'],
exclude: [
'**/generated-metadata/**',
'**/testing/mock-data/generated/**',
@@ -135,8 +137,7 @@ export default defineConfig(({ command, mode }) => {
plugins: ['@babel/plugin-transform-export-namespace-from'],
},
}),
enforce: 'pre',
},
),
visualizer({
open: true,
gzipSize: true,
+9
View File
@@ -95,6 +95,11 @@
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./vite": {
"types": "./dist/vite/index.d.ts",
"import": "./dist/vite.mjs",
"require": "./dist/vite.cjs"
},
"./workflow": {
"types": "./dist/workflow/index.d.ts",
"import": "./dist/workflow.mjs",
@@ -118,6 +123,7 @@
"translations",
"types",
"utils",
"vite",
"workflow",
"workspace"
],
@@ -153,6 +159,9 @@
"utils": [
"dist/utils/index.d.ts"
],
"vite": [
"dist/vite/index.d.ts"
],
"workflow": [
"dist/workflow/index.d.ts"
],
+2
View File
@@ -40,6 +40,8 @@
"{projectRoot}/types/dist",
"{projectRoot}/utils/package.json",
"{projectRoot}/utils/dist",
"{projectRoot}/vite/package.json",
"{projectRoot}/vite/dist",
"{projectRoot}/workflow/package.json",
"{projectRoot}/workflow/dist",
"{projectRoot}/workspace/package.json",
@@ -0,0 +1,98 @@
/* eslint-disable no-console */
import { type Plugin } from 'vite';
const LINARIA_IMPORT_RE = /@linaria/;
type WywProfilingOptions = {
slowThresholdMs?: number;
topSlowFilesCount?: number;
progressIntervalFiles?: number;
};
export const createWywProfilingPlugin = (
wywPlugin: Plugin,
options?: WywProfilingOptions,
): Plugin => {
const slowThresholdMs = options?.slowThresholdMs ?? 50;
const topSlowFilesCount = options?.topSlowFilesCount ?? 10;
const progressIntervalFiles = options?.progressIntervalFiles ?? 50;
let totalMs = 0;
let fileCount = 0;
let skippedCount = 0;
const slowFiles: { id: string; ms: number }[] = [];
const originalTransform = wywPlugin.transform;
console.log(
`[linaria/wyw] CSS pre-build profiling enabled (slow threshold: ${slowThresholdMs}ms)`,
);
return {
...wywPlugin,
enforce: 'pre' as const,
transform(code: string, id: string, ...rest: unknown[]) {
if (!LINARIA_IMPORT_RE.test(code)) {
skippedCount++;
return null;
}
const start = performance.now();
const result = (originalTransform as Function).call(
this,
code,
id,
...rest,
);
const handleTiming = (elapsed: number) => {
totalMs += elapsed;
fileCount++;
if (elapsed > slowThresholdMs) {
slowFiles.push({ id, ms: elapsed });
}
if (fileCount % progressIntervalFiles === 0) {
console.log(
`[linaria/wyw] CSS pre-build progress: ${fileCount} transformed, ${skippedCount} skipped, ${totalMs.toFixed(0)}ms total`,
);
}
};
if (result && typeof result === 'object' && 'then' in result) {
return (result as Promise<unknown>).then((res) => {
handleTiming(performance.now() - start);
return res;
});
}
handleTiming(performance.now() - start);
return result;
},
buildEnd() {
console.log('\n[linaria/wyw] ===== CSS PRE-BUILD TIMING SUMMARY =====');
console.log(`[linaria/wyw] Files transformed: ${fileCount}`);
console.log(`[linaria/wyw] Files skipped (no @linaria): ${skippedCount}`);
console.log(`[linaria/wyw] Transform time: ${totalMs.toFixed(0)}ms`);
console.log(
`[linaria/wyw] Avg per transformed file: ${fileCount > 0 ? (totalMs / fileCount).toFixed(1) : 0}ms`,
);
if (slowFiles.length > 0) {
console.log(
`[linaria/wyw] Slow CSS pre-build files (>${slowThresholdMs}ms):`,
);
slowFiles
.sort((a, b) => b.ms - a.ms)
.slice(0, topSlowFilesCount)
.forEach((slowFile) =>
console.log(
`[linaria/wyw] ${slowFile.ms.toFixed(0)}ms ${slowFile.id.replace(process.cwd(), '')}`,
),
);
}
console.log('[linaria/wyw] ==========================================\n');
},
};
};
+10
View File
@@ -0,0 +1,10 @@
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export { createWywProfilingPlugin } from './createWywProfilingPlugin';
+1
View File
@@ -14,6 +14,7 @@ export default [
{
ignores: [
'**/node_modules/**',
'**/generated/**',
],
},
@@ -67,10 +67,7 @@ export const AvatarOrIcon = ({
if (isIconInverted || isDefined(IconBackgroundColor)) {
return (
<StyledAvatarOrIconWrapper
isClickable={isClickable}
onClick={onClick}
>
<StyledAvatarOrIconWrapper isClickable={isClickable} onClick={onClick}>
<StyledIconWithBackgroundContainer
backgroundColor={
IconBackgroundColor ?? theme.background.invertedSecondary
@@ -87,10 +84,7 @@ export const AvatarOrIcon = ({
}
return (
<StyledAvatarOrIconWrapper
isClickable={isClickable}
onClick={onClick}
>
<StyledAvatarOrIconWrapper isClickable={isClickable} onClick={onClick}>
<Icon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
@@ -140,8 +140,8 @@ const StyledContainer = styled.div<
`;
const StyledRightComponentDivider = styled.div`
border-left: 1px solid ${themeCssVariables.border.color.light};
align-self: stretch;
border-left: 1px solid ${themeCssVariables.border.color.light};
`;
const renderRightComponent = (
@@ -155,7 +155,7 @@ const renderRightComponent = (
const rendered =
typeof rightComponent === 'function' ? rightComponent() : rightComponent;
if (rightComponentDivider) {
if (rightComponentDivider === true) {
return (
<>
<StyledRightComponentDivider />
@@ -13,4 +13,3 @@ export * from './layout';
export * from './navigation';
export * from './theme';
export * from './utilities';
@@ -41,10 +41,12 @@ const StyledJsonListBase = styled.ul<{
padding: 0;
display: grid;
row-gap: ${themeCssVariables.spacing[2]};
padding-left: ${({ depth }) => (depth > 0 ? themeCssVariables.spacing[8] : '0')};
padding-left: ${({ depth }) =>
depth > 0 ? themeCssVariables.spacing[8] : '0'};
> :first-of-type {
margin-top: ${({ depth }) => (depth > 0 ? themeCssVariables.spacing[2] : '0')};
margin-top: ${({ depth }) =>
depth > 0 ? themeCssVariables.spacing[2] : '0'};
}
`;
+12
View File
@@ -39,6 +39,18 @@ export { Card } from './card/components/Card';
export { CardContent } from './card/components/CardContent';
export { CardFooter } from './card/components/CardFooter';
export { CardHeader } from './card/components/CardHeader';
export { Modal } from './modal/components/Modal';
export { ModalBackdrop } from './modal/components/ModalBackdrop';
export type { ModalContentProps } from './modal/components/ModalContent';
export { ModalContent } from './modal/components/ModalContent';
export type { ModalFooterProps } from './modal/components/ModalFooter';
export { ModalFooter } from './modal/components/ModalFooter';
export type { ModalHeaderProps } from './modal/components/ModalHeader';
export { ModalHeader } from './modal/components/ModalHeader';
export type { ModalOverlay } from './modal/types/ModalOverlay';
export type { ModalPadding } from './modal/types/ModalPadding';
export type { ModalProps } from './modal/types/ModalProps';
export type { ModalSize } from './modal/types/ModalSize';
export {
SectionAlignment,
SectionFontColor,
@@ -0,0 +1,182 @@
import { styled } from '@linaria/react';
import { AnimatePresence, motion } from 'framer-motion';
import React, { useContext, useRef } from 'react';
import { createPortal } from 'react-dom';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from '@ui/theme-constants';
import { ThemeContext } from '@ui/theme';
import { type ModalOverlay } from '../types/ModalOverlay';
import { type ModalPadding } from '../types/ModalPadding';
import { type ModalProps } from '../types/ModalProps';
import { type ModalSize } from '../types/ModalSize';
import { ModalBackdrop } from './ModalBackdrop';
const DEFAULT_MODAL_Z_INDEX = 40;
const DEFAULT_BACKDROP_Z_INDEX = 39;
const StyledModalDiv = styled.div<{
size?: ModalSize;
padding?: ModalPadding;
isMobile: boolean;
overlay: ModalOverlay;
gap?: number;
smallBorderRadius?: boolean;
narrowWidth?: boolean;
autoHeight?: boolean;
modalZIndex: number;
}>`
display: flex;
flex-direction: column;
box-shadow: ${({ overlay }) =>
overlay === 'dark'
? themeCssVariables.boxShadow.superHeavy
: overlay === 'transparent'
? 'none'
: themeCssVariables.boxShadow.strong};
background: ${({ overlay }) =>
overlay === 'transparent'
? 'transparent'
: themeCssVariables.background.primary};
color: ${themeCssVariables.font.color.primary};
border-radius: ${({ isMobile, overlay, smallBorderRadius }) => {
if (isMobile === true || overlay === 'transparent') return '0';
if (smallBorderRadius === true) return themeCssVariables.spacing[1];
return themeCssVariables.border.radius.md;
}};
overflow-x: hidden;
overflow-y: auto;
z-index: ${({ modalZIndex }) => modalZIndex};
gap: ${({ gap }) =>
gap !== undefined ? `var(--t-spacing-${gap})` : 'unset'};
width: ${({ isMobile, size, narrowWidth }) => {
if (narrowWidth === true)
return `calc(400px - ${themeCssVariables.spacing[32]})`;
if (isMobile)
return themeCssVariables.modal.size.fullscreen.width ?? 'auto';
switch (size) {
case 'small':
return themeCssVariables.modal.size.sm.width ?? 'auto';
case 'medium':
return themeCssVariables.modal.size.md.width ?? 'auto';
case 'large':
return themeCssVariables.modal.size.lg.width ?? 'auto';
case 'extraLarge':
return themeCssVariables.modal.size.xl.width ?? 'auto';
default:
return 'auto';
}
}};
padding: ${({ padding }) => {
switch (padding) {
case 'none':
return themeCssVariables.spacing[0];
case 'small':
return themeCssVariables.spacing[2];
case 'medium':
return themeCssVariables.spacing[4];
case 'large':
return themeCssVariables.spacing[6];
default:
return 'auto';
}
}};
height: ${({ isMobile, size, autoHeight }) => {
if (autoHeight === true) return 'auto';
if (isMobile)
return themeCssVariables.modal.size.fullscreen.height ?? 'auto';
switch (size) {
case 'extraLarge':
return themeCssVariables.modal.size.xl.height ?? 'auto';
default:
return 'auto';
}
}};
max-height: ${({ isMobile }) => (isMobile ? 'none' : '90dvh')};
`;
const AnimatedModalDiv = motion.create(StyledModalDiv);
const AnimatedBackdrop = motion.create(ModalBackdrop);
const modalAnimation = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
isOpen,
children,
size = 'medium',
padding = 'medium',
overlay = 'dark',
isMobile = false,
isInContainer = false,
container,
gap,
smallBorderRadius,
narrowWidth,
autoHeight,
modalZIndex = DEFAULT_MODAL_Z_INDEX,
backdropZIndex = DEFAULT_BACKDROP_Z_INDEX,
backdropTestId = 'modal-backdrop',
backdropClickOutsideId,
preventClickOutside,
onBackdropMouseDown,
modalRef: externalRef,
}: ModalProps) => {
const internalRef = useRef<HTMLDivElement>(null);
const resolvedRef = externalRef ?? internalRef;
const { theme } = useContext(ThemeContext);
const handleBackdropMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onBackdropMouseDown?.(e);
};
const content = (
<AnimatePresence mode="wait">
{isOpen && (
<AnimatedBackdrop
data-testid={backdropTestId}
data-click-outside-id={backdropClickOutsideId}
onMouseDown={handleBackdropMouseDown}
overlay={overlay}
backdropZIndex={backdropZIndex}
isInContainer={isInContainer}
>
<AnimatedModalDiv
ref={resolvedRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
overlay={overlay}
variants={modalAnimation}
transition={{ duration: theme.animation.duration.normal }}
isMobile={isMobile}
gap={gap}
smallBorderRadius={smallBorderRadius}
narrowWidth={narrowWidth}
autoHeight={autoHeight}
modalZIndex={modalZIndex}
data-globally-prevent-click-outside={preventClickOutside}
>
{children}
</AnimatedModalDiv>
</AnimatedBackdrop>
)}
</AnimatePresence>
);
if (isDefined(container)) {
return createPortal(content, container);
}
return content;
};
@@ -0,0 +1,28 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from '@ui/theme-constants';
import { type ModalOverlay } from '../types/ModalOverlay';
const StyledModalBackdrop = styled.div<{
overlay: ModalOverlay;
backdropZIndex: number;
isInContainer?: boolean;
}>`
align-items: center;
background: ${({ overlay, isInContainer }) =>
isInContainer || overlay === 'light'
? themeCssVariables.background.overlayTertiary
: themeCssVariables.background.overlayPrimary};
display: flex;
height: 100%;
justify-content: center;
left: 0;
pointer-events: auto;
position: ${({ isInContainer }) => (isInContainer ? 'absolute' : 'fixed')};
top: 0;
width: 100%;
z-index: ${({ backdropZIndex }) => backdropZIndex};
user-select: none;
`;
export const ModalBackdrop = StyledModalBackdrop;
@@ -0,0 +1,59 @@
import { styled } from '@linaria/react';
import React from 'react';
import { themeCssVariables } from '@ui/theme-constants';
const StyledContent = styled.div<{
isVerticallyCentered?: boolean;
isHorizontallyCentered?: boolean;
noPadding?: boolean;
overflowHidden?: boolean;
gap?: number;
contentPadding?: number;
}>`
align-items: ${({ isVerticallyCentered }) =>
isVerticallyCentered ? 'center' : 'stretch'};
display: flex;
flex: 1 1 0%;
flex-direction: column;
gap: ${({ gap }) =>
gap !== undefined ? `var(--t-spacing-${gap})` : 'unset'};
justify-content: ${({ isHorizontallyCentered }) =>
isHorizontallyCentered ? 'center' : 'flex-start'};
overflow: ${({ overflowHidden }) => (overflowHidden ? 'hidden' : 'visible')};
padding: ${({ noPadding, contentPadding }) => {
if (noPadding === true) return '0';
if (contentPadding !== undefined)
return `var(--t-spacing-${contentPadding})`;
return themeCssVariables.spacing[10];
}};
`;
export type ModalContentProps = React.PropsWithChildren & {
isVerticallyCentered?: boolean;
isHorizontallyCentered?: boolean;
noPadding?: boolean;
overflowHidden?: boolean;
gap?: number;
contentPadding?: number;
};
export const ModalContent = ({
children,
isVerticallyCentered,
isHorizontallyCentered,
noPadding,
overflowHidden,
gap,
contentPadding,
}: ModalContentProps) => (
<StyledContent
isVerticallyCentered={isVerticallyCentered}
isHorizontallyCentered={isHorizontallyCentered}
noPadding={noPadding}
overflowHidden={overflowHidden}
gap={gap}
contentPadding={contentPadding}
>
{children}
</StyledContent>
);
@@ -0,0 +1,43 @@
import { styled } from '@linaria/react';
import React from 'react';
import { themeCssVariables } from '@ui/theme-constants';
const StyledFooter = styled.div<{
autoHeight?: boolean;
centered?: boolean;
smallPadding?: boolean;
}>`
align-items: center;
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
height: ${({ autoHeight }) => (autoHeight ? 'auto' : '60px')};
justify-content: ${({ centered }) => (centered ? 'center' : 'flex-end')};
overflow: hidden;
padding: ${({ smallPadding }) =>
smallPadding ? themeCssVariables.spacing[3] : themeCssVariables.spacing[5]};
`;
export type ModalFooterProps = React.PropsWithChildren & {
autoHeight?: boolean;
centered?: boolean;
smallPadding?: boolean;
className?: string;
};
export const ModalFooter = ({
children,
autoHeight,
centered,
smallPadding,
className,
}: ModalFooterProps) => (
<StyledFooter
autoHeight={autoHeight}
centered={centered}
smallPadding={smallPadding}
className={className}
>
{children}
</StyledFooter>
);
@@ -0,0 +1,65 @@
import { styled } from '@linaria/react';
import React from 'react';
import { MOBILE_VIEWPORT, themeCssVariables } from '@ui/theme-constants';
const StyledHeader = styled.div<{
noPadding?: boolean;
autoHeight?: boolean;
hasBorderBottom?: boolean;
paddingHorizontal?: number;
backgroundColor?: string;
}>`
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
height: ${({ autoHeight }) => (autoHeight ? 'auto' : '60px')};
overflow: hidden;
padding: ${({ noPadding, paddingHorizontal }) => {
if (paddingHorizontal !== undefined)
return `0 var(--t-spacing-${paddingHorizontal})`;
if (noPadding === true) return '0';
return themeCssVariables.spacing[5];
}};
background-color: ${({ backgroundColor }) => backgroundColor ?? 'unset'};
border-bottom: ${({ hasBorderBottom }) =>
hasBorderBottom
? `1px solid ${themeCssVariables.border.color.medium}`
: 'none'};
@media (max-width: ${MOBILE_VIEWPORT}px) {
${({ paddingHorizontal }) =>
paddingHorizontal !== undefined
? `padding-left: ${themeCssVariables.spacing[4]}; padding-right: ${themeCssVariables.spacing[4]};`
: ''}
}
`;
export type ModalHeaderProps = React.PropsWithChildren & {
noPadding?: boolean;
autoHeight?: boolean;
hasBorderBottom?: boolean;
paddingHorizontal?: number;
backgroundColor?: string;
className?: string;
};
export const ModalHeader = ({
children,
noPadding,
autoHeight,
hasBorderBottom,
paddingHorizontal,
backgroundColor,
className,
}: ModalHeaderProps) => (
<StyledHeader
noPadding={noPadding}
autoHeight={autoHeight}
hasBorderBottom={hasBorderBottom}
paddingHorizontal={paddingHorizontal}
backgroundColor={backgroundColor}
className={className}
>
{children}
</StyledHeader>
);
@@ -0,0 +1,244 @@
import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { H1Title, H1TitleFontColor, H2Title, IconX } from '@ui/display';
import { Button, IconButton } from '@ui/input';
import { Section, SectionAlignment, SectionFontColor } from '@ui/layout';
import { ComponentDecorator } from '@ui/testing';
import { themeCssVariables } from '@ui/theme-constants';
import { Modal } from '../Modal';
import { ModalContent } from '../ModalContent';
import { ModalFooter } from '../ModalFooter';
import { ModalHeader } from '../ModalHeader';
const StyledCenteredTitle = styled.div`
text-align: center;
`;
const StyledSection = styled(Section)`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const meta: Meta<typeof Modal> = {
title: 'UI/Layout/Modal/Modal',
component: Modal,
decorators: [ComponentDecorator],
argTypes: {
size: {
control: 'select',
options: ['small', 'medium', 'large', 'extraLarge'],
},
padding: {
control: 'select',
options: ['none', 'small', 'medium', 'large'],
},
overlay: {
control: 'select',
options: ['light', 'dark', 'transparent'],
},
},
};
export default meta;
type Story = StoryObj<typeof Modal>;
export const Default: Story = {
args: {
isOpen: true,
size: 'medium',
padding: 'none',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalHeader>
<H2Title
title="Edit workspace"
description="Update your workspace settings"
/>
</ModalHeader>
<ModalContent>
<Section>
Workspace name and subdomain can be changed from the settings panel.
These changes will be reflected across all members.
</Section>
</ModalContent>
<ModalFooter>
<Button title="Cancel" variant="secondary" />
<Button title="Save" variant="primary" accent="blue" />
</ModalFooter>
</Modal>
),
};
export const Confirmation: Story = {
args: {
isOpen: true,
padding: 'large',
overlay: 'dark',
smallBorderRadius: true,
narrowWidth: true,
autoHeight: true,
gap: 2,
},
render: ({
isOpen,
padding,
overlay,
smallBorderRadius,
narrowWidth,
autoHeight,
gap,
}) => (
<Modal
isOpen={isOpen}
padding={padding}
overlay={overlay}
smallBorderRadius={smallBorderRadius}
narrowWidth={narrowWidth}
autoHeight={autoHeight}
gap={gap}
>
<StyledCenteredTitle>
<H1Title title="Delete record?" fontColor={H1TitleFontColor.Primary} />
</StyledCenteredTitle>
<StyledSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
This action cannot be undone. The record and all of its data will be
permanently removed.
</StyledSection>
<Button title="Cancel" variant="secondary" fullWidth justify="center" />
<Button
title="Delete"
variant="secondary"
accent="danger"
fullWidth
justify="center"
/>
</Modal>
),
};
export const Small: Story = {
args: {
isOpen: true,
size: 'small',
padding: 'none',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalHeader>
<H2Title title="Archive item" />
</ModalHeader>
<ModalContent>
<Section>Are you sure you want to archive this item?</Section>
</ModalContent>
<ModalFooter>
<Button title="No" variant="secondary" />
<Button title="Yes, archive" variant="primary" accent="blue" />
</ModalFooter>
</Modal>
),
};
export const ExtraLarge: Story = {
args: {
isOpen: true,
size: 'extraLarge',
padding: 'none',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalHeader>
<H2Title
title="Import contacts"
description="Upload a CSV file to import your contacts"
/>
</ModalHeader>
<ModalContent>
<Section>
The file should include columns for name, email, phone, and company.
Drag and drop your CSV file here, or click to browse.
</Section>
</ModalContent>
<ModalFooter>
<Button title="Cancel" variant="secondary" />
<Button title="Upload & import" variant="primary" accent="blue" />
</ModalFooter>
</Modal>
),
};
export const Closed: Story = {
args: {
isOpen: false,
size: 'medium',
padding: 'medium',
overlay: 'dark',
},
render: ({ isOpen, size, padding, overlay }) => (
<Modal isOpen={isOpen} size={size} padding={padding} overlay={overlay}>
<ModalContent>This should not be visible.</ModalContent>
</Modal>
),
};
const InteractiveModal = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
title="Open Modal"
variant="primary"
accent="blue"
onClick={() => setIsOpen(true)}
/>
<Modal
isOpen={isOpen}
size="medium"
padding="none"
overlay="dark"
onBackdropMouseDown={() => setIsOpen(false)}
>
<ModalHeader>
<H2Title title="Create record" />
<IconButton
Icon={IconX}
variant="tertiary"
size="small"
onClick={() => setIsOpen(false)}
/>
</ModalHeader>
<ModalContent>
<Section>
Fill in the details below to create a new record. All fields are
optional.
</Section>
</ModalContent>
<ModalFooter>
<Button
title="Cancel"
variant="secondary"
onClick={() => setIsOpen(false)}
/>
<Button
title="Create"
variant="primary"
accent="blue"
onClick={() => setIsOpen(false)}
/>
</ModalFooter>
</Modal>
</>
);
};
export const Interactive: Story = {
render: () => <InteractiveModal />,
};
@@ -0,0 +1,12 @@
export { Modal } from './components/Modal';
export { ModalBackdrop } from './components/ModalBackdrop';
export { ModalContent } from './components/ModalContent';
export type { ModalContentProps } from './components/ModalContent';
export { ModalFooter } from './components/ModalFooter';
export type { ModalFooterProps } from './components/ModalFooter';
export { ModalHeader } from './components/ModalHeader';
export type { ModalHeaderProps } from './components/ModalHeader';
export type { ModalOverlay } from './types/ModalOverlay';
export type { ModalPadding } from './types/ModalPadding';
export type { ModalProps } from './types/ModalProps';
export type { ModalSize } from './types/ModalSize';
@@ -0,0 +1 @@
export type ModalOverlay = 'light' | 'dark' | 'transparent';
@@ -0,0 +1 @@
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
@@ -0,0 +1,26 @@
import type React from 'react';
import { type ModalOverlay } from './ModalOverlay';
import { type ModalPadding } from './ModalPadding';
import { type ModalSize } from './ModalSize';
export type ModalProps = React.PropsWithChildren & {
isOpen: boolean;
size?: ModalSize;
padding?: ModalPadding;
overlay?: ModalOverlay;
isMobile?: boolean;
isInContainer?: boolean;
container?: HTMLElement | null;
gap?: number;
smallBorderRadius?: boolean;
narrowWidth?: boolean;
autoHeight?: boolean;
modalZIndex?: number;
backdropZIndex?: number;
backdropTestId?: string;
backdropClickOutsideId?: string;
preventClickOutside?: boolean;
onBackdropMouseDown?: (e: React.MouseEvent) => void;
modalRef?: React.RefObject<HTMLDivElement>;
};
@@ -0,0 +1 @@
export type ModalSize = 'small' | 'medium' | 'large' | 'extraLarge';
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -1,6 +1,7 @@
import react from '@vitejs/plugin-react-swc';
import wyw from '@wyw-in-js/vite';
import * as path from 'path';
import { createWywProfilingPlugin } from 'twenty-shared/vite';
import { defineConfig } from 'vite';
import checker from 'vite-plugin-checker';
import dts, { type PluginOptions } from 'vite-plugin-dts';
@@ -81,15 +82,14 @@ export default defineConfig(({ command }) => {
svgr(),
dts(dtsConfig),
checker(checkersConfig),
{
...wyw({
createWywProfilingPlugin(
wyw({
include: [path.resolve(__dirname, 'src') + '/**/*.{ts,tsx}'],
babelOptions: {
presets: ['@babel/preset-typescript', '@babel/preset-react'],
},
}),
enforce: 'pre',
},
),
],
build: {
cssCodeSplit: false,