Redesign the external link popup for front components (#23404)

<img width="3840" height="1866" alt="CleanShot 2026-07-28 at 11 52
15@2x"
src="https://github.com/user-attachments/assets/de081951-6b1f-4194-8452-8395a90f3746"
/>


Applies the Figma design to the confirmation popup shown before a front
component navigates to an external site.

New copy: "Open external link?", the destination as a pill, "Always
allow links to this domain", and "Open link" as the confirm button. The
popup is now its own component built on `ModalStatefulWrapper` because
`ConfirmationModal`'s fixed spacing cannot produce the design's layout.

The "always allow" checkbox stays checked by default, as before. The
pill is a non-interactive span rather than a `RoundedLink`, so the
destination cannot be opened outside the confirm flow, and it ellipsizes
the path so the domain stays readable.
This commit is contained in:
Raphaël Bosi
2026-07-28 12:08:17 +02:00
committed by GitHub
parent 74260a161d
commit 2bd1ece952
6 changed files with 273 additions and 85 deletions
@@ -0,0 +1,153 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useId } from 'react';
import { Button, Checkbox } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H1Title, H1TitleFontColor } from 'twenty-ui/typography';
import { FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID } from '@/front-components/constants/FrontComponentExternalLinkModalId';
import { getExternalLinkDisplayUrl } from '@/front-components/utils/getExternalLinkDisplayUrl';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
const FRONT_COMPONENT_EXTERNAL_LINK_MODAL_WIDTH = 320;
const StyledCenteredTitle = styled.div`
text-align: center;
h2 {
margin-bottom: 0;
}
`;
const StyledDestinationUrl = styled.span`
align-items: center;
align-self: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.strong};
border-radius: ${themeCssVariables.border.radius.pill};
color: ${themeCssVariables.font.color.primary};
corner-shape: round;
display: inline-flex;
font-size: ${themeCssVariables.font.size.md};
height: 20px;
justify-content: center;
max-width: 100%;
overflow: hidden;
padding: 0 ${themeCssVariables.spacing[2]};
`;
const StyledDestinationUrlText = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledActions = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledTrustOriginRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
justify-content: center;
`;
const StyledTrustOriginLabel = styled.span`
color: ${themeCssVariables.font.color.primary};
cursor: pointer;
font-size: ${themeCssVariables.font.size.sm};
`;
type FrontComponentExternalLinkModalProps = {
url: string;
shouldTrustOrigin: boolean;
onShouldTrustOriginChange: (shouldTrustOrigin: boolean) => void;
onConfirm: () => void;
onClose: () => void;
};
export const FrontComponentExternalLinkModal = ({
url,
shouldTrustOrigin,
onShouldTrustOriginChange,
onConfirm,
onClose,
}: FrontComponentExternalLinkModalProps) => {
const { closeModal } = useModal();
const trustOriginLabelId = useId();
const handleConfirmClick = () => {
closeModal(FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID);
onConfirm();
};
const handleCancelClick = () => {
closeModal(FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID);
onClose();
};
return (
<ModalStatefulWrapper
modalInstanceId={FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID}
onClose={onClose}
onEnter={handleConfirmClick}
isClosable={true}
padding="large"
gap={6}
dataGloballyPreventClickOutside
renderInDocumentBody
smallBorderRadius
width={FRONT_COMPONENT_EXTERNAL_LINK_MODAL_WIDTH}
autoHeight
>
<StyledCenteredTitle>
<H1Title
title={t`Open external link?`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledCenteredTitle>
<StyledDestinationUrl>
<StyledDestinationUrlText>
{getExternalLinkDisplayUrl(url)}
</StyledDestinationUrlText>
</StyledDestinationUrl>
<StyledActions>
<StyledTrustOriginRow>
<Checkbox
checked={shouldTrustOrigin}
onCheckedChange={onShouldTrustOriginChange}
aria-labelledby={trustOriginLabelId}
/>
<StyledTrustOriginLabel
id={trustOriginLabelId}
onClick={() => onShouldTrustOriginChange(!shouldTrustOrigin)}
>
{t`Always allow links to this domain`}
</StyledTrustOriginLabel>
</StyledTrustOriginRow>
<Button
onClick={handleCancelClick}
variant="secondary"
title={t`Cancel`}
fullWidth
justify="center"
dataTestId="front-component-external-link-modal-cancel-button"
/>
<Button
onClick={handleConfirmClick}
variant="primary"
accent="blue"
title={t`Open link`}
fullWidth
justify="center"
dataTestId="front-component-external-link-modal-confirm-button"
/>
</StyledActions>
</ModalStatefulWrapper>
);
};
@@ -1,13 +1,10 @@
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentExternalLinkModalSubtitle } from '@/front-components/components/FrontComponentExternalLinkModalSubtitle';
import { FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID } from '@/front-components/constants/FrontComponentExternalLinkModalId';
import { FrontComponentExternalLinkModal } from '@/front-components/components/FrontComponentExternalLinkModal';
import { frontComponentExternalLinkModalConfigState } from '@/front-components/states/frontComponentExternalLinkModalConfigState';
import { trustedFrontComponentExternalOriginsState } from '@/front-components/states/trustedFrontComponentExternalOriginsState';
import { openExternalUrl } from '@/front-components/utils/openExternalUrl';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -29,7 +26,7 @@ export const FrontComponentExternalLinkModalManager = () => {
const { applicationId, url, origin } = frontComponentExternalLinkModalConfig;
const handleConfirmClick = () => {
const handleConfirm = () => {
if (shouldTrustOrigin) {
setTrustedFrontComponentExternalOrigins((previousTrustedOrigins) => ({
...previousTrustedOrigins,
@@ -51,20 +48,11 @@ export const FrontComponentExternalLinkModalManager = () => {
};
return (
<ConfirmationModal
modalInstanceId={FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID}
title={t`You're leaving Twenty`}
subtitle={
<FrontComponentExternalLinkModalSubtitle
url={url}
origin={origin}
shouldTrustOrigin={shouldTrustOrigin}
onShouldTrustOriginChange={setShouldTrustOrigin}
/>
}
confirmButtonText={t`Continue`}
confirmButtonAccent="blue"
onConfirmClick={handleConfirmClick}
<FrontComponentExternalLinkModal
url={url}
shouldTrustOrigin={shouldTrustOrigin}
onShouldTrustOriginChange={setShouldTrustOrigin}
onConfirm={handleConfirm}
onClose={handleClose}
/>
);
@@ -1,66 +0,0 @@
import { styled } from '@linaria/react';
import { Trans } from '@lingui/react/macro';
import { useId } from 'react';
import { Checkbox } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContent = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
overflow-wrap: anywhere;
`;
const StyledTrustRow = styled.div`
align-items: center;
align-self: stretch;
display: flex;
gap: ${themeCssVariables.spacing[2]};
text-align: left;
`;
const StyledTrustLabel = styled.span`
cursor: pointer;
`;
type FrontComponentExternalLinkModalSubtitleProps = {
url: string;
origin: string;
shouldTrustOrigin: boolean;
onShouldTrustOriginChange: (shouldTrustOrigin: boolean) => void;
};
export const FrontComponentExternalLinkModalSubtitle = ({
url,
origin,
shouldTrustOrigin,
onShouldTrustOriginChange,
}: FrontComponentExternalLinkModalSubtitleProps) => {
const trustOriginLabelId = useId();
return (
<StyledContent>
<span>
<Trans>
This link will take you to an external site: <strong>{url}</strong>
</Trans>
</span>
<StyledTrustRow>
<Checkbox
checked={shouldTrustOrigin}
onCheckedChange={onShouldTrustOriginChange}
aria-labelledby={trustOriginLabelId}
/>
<StyledTrustLabel
id={trustOriginLabelId}
onClick={() => onShouldTrustOriginChange(!shouldTrustOrigin)}
>
<Trans>
Don't ask again for <strong>{origin}</strong>
</Trans>
</StyledTrustLabel>
</StyledTrustRow>
</StyledContent>
);
};
@@ -0,0 +1,72 @@
import {
type Decorator,
type Meta,
type StoryObj,
} from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { FrontComponentExternalLinkModal } from '@/front-components/components/FrontComponentExternalLinkModal';
import { FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID } from '@/front-components/constants/FrontComponentExternalLinkModalId';
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 { ComponentDecorator } from 'twenty-ui/testing';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
const OpenedModalDecorator: Decorator = (Story) => {
jotaiStore.set(
isModalOpenedComponentState.atomFamily({
instanceId: FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID,
}),
true,
);
jotaiStore.set(focusStackState.atom, [
{
focusId: FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID,
componentInstance: {
componentType: FocusComponentType.MODAL,
componentInstanceId: FRONT_COMPONENT_EXTERNAL_LINK_MODAL_ID,
},
globalHotkeysConfig: {
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: true,
},
},
]);
return <Story />;
};
const meta: Meta<typeof FrontComponentExternalLinkModal> = {
title: 'Modules/FrontComponents/FrontComponentExternalLinkModal',
component: FrontComponentExternalLinkModal,
decorators: [OpenedModalDecorator, RootDecorator, ComponentDecorator],
parameters: {
disableHotkeyInitialization: true,
},
args: {
url: 'https://nvidia.com',
shouldTrustOrigin: true,
onShouldTrustOriginChange: fn(),
onConfirm: fn(),
onClose: fn(),
},
};
export default meta;
type Story = StoryObj<typeof FrontComponentExternalLinkModal>;
export const Default: Story = {};
export const WithTrustedOriginUnchecked: Story = {
args: {
shouldTrustOrigin: false,
},
};
export const WithLongUrl: Story = {
args: {
url: 'https://developer.nvidia.com/blog/category/generative-ai/very-long-article-slug?utm_source=twenty',
},
};
@@ -0,0 +1,29 @@
import { getExternalLinkDisplayUrl } from '@/front-components/utils/getExternalLinkDisplayUrl';
describe('getExternalLinkDisplayUrl', () => {
it('should strip the scheme and the trailing slash of a bare domain', () => {
expect(getExternalLinkDisplayUrl('https://nvidia.com/')).toBe('nvidia.com');
});
it('should strip the www subdomain', () => {
expect(getExternalLinkDisplayUrl('https://www.nvidia.com')).toBe(
'nvidia.com',
);
});
it('should keep the path, the search params and the hash', () => {
expect(
getExternalLinkDisplayUrl('https://nvidia.com/drivers?os=mac#latest'),
).toBe('nvidia.com/drivers?os=mac#latest');
});
it('should keep the port', () => {
expect(getExternalLinkDisplayUrl('http://localhost:3000/app')).toBe(
'localhost:3000/app',
);
});
it('should return the untouched value when the url cannot be parsed', () => {
expect(getExternalLinkDisplayUrl('not-a-url')).toBe('not-a-url');
});
});
@@ -0,0 +1,12 @@
export const getExternalLinkDisplayUrl = (url: string) => {
try {
const { host, pathname, search, hash } = new URL(url);
const displayedHost = host.startsWith('www.') ? host.slice(4) : host;
const displayedPathname = pathname === '/' ? '' : pathname;
return `${displayedHost}${displayedPathname}${search}${hash}`;
} catch {
return url;
}
};