message campaign redesign (#22508)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22508?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
neo773
2026-07-05 17:14:34 +05:30
committed by GitHub
parent e90ab56c7a
commit 904957ea1e
19 changed files with 423 additions and 357 deletions
@@ -182,22 +182,24 @@ const SettingsLegalDpaNew = lazy(() =>
})),
);
const SettingsWorkspaceEmail = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceEmail').then((module) => ({
default: module.SettingsWorkspaceEmail,
})),
const SettingsWorkspaceCommunications = lazy(() =>
import('~/pages/settings/communications/SettingsWorkspaceCommunications').then(
(module) => ({
default: module.SettingsWorkspaceCommunications,
}),
),
);
const SettingsWorkspaceEmailGroupChannelDetail = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceEmailGroupChannelDetail').then(
const SettingsWorkspaceCommunicationGroupChannelDetail = lazy(() =>
import('~/pages/settings/communications/SettingsWorkspaceCommunicationGroupChannelDetail').then(
(module) => ({
default: module.SettingsWorkspaceEmailGroupChannelDetail,
default: module.SettingsWorkspaceCommunicationGroupChannelDetail,
}),
),
);
const SettingsWorkspaceNewUnsubscribeTopic = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceNewUnsubscribeTopic').then(
import('~/pages/settings/communications/SettingsWorkspaceNewUnsubscribeTopic').then(
(module) => ({
default: module.SettingsWorkspaceNewUnsubscribeTopic,
}),
@@ -205,7 +207,7 @@ const SettingsWorkspaceNewUnsubscribeTopic = lazy(() =>
);
const SettingsWorkspaceUnsubscribeTopicDetail = lazy(() =>
import('~/pages/settings/email/SettingsWorkspaceUnsubscribeTopicDetail').then(
import('~/pages/settings/communications/SettingsWorkspaceUnsubscribeTopicDetail').then(
(module) => ({
default: module.SettingsWorkspaceUnsubscribeTopicDetail,
}),
@@ -703,8 +705,8 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
>
<Route path={SettingsPath.General} element={<SettingsGeneral />} />
<Route
path={SettingsPath.WorkspaceEmail}
element={<SettingsWorkspaceEmail />}
path={SettingsPath.WorkspaceCommunications}
element={<SettingsWorkspaceCommunications />}
/>
<Route
path={SettingsPath.NewEmailGroupChannel}
@@ -712,7 +714,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
/>
<Route
path={SettingsPath.EmailGroupChannelDetail}
element={<SettingsWorkspaceEmailGroupChannelDetail />}
element={<SettingsWorkspaceCommunicationGroupChannelDetail />}
/>
<Route
path={SettingsPath.NewUnsubscribeTopic}
@@ -53,8 +53,8 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Email`,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
children: t`Communications`,
href: getSettingsPath(SettingsPath.WorkspaceCommunications),
},
{ children: t`New Email Channel` },
]}
@@ -63,7 +63,7 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
isSaveDisabled={!canSave}
isCancelDisabled={loading}
isLoading={loading}
onCancel={() => navigate(SettingsPath.WorkspaceEmail)}
onCancel={() => navigate(SettingsPath.WorkspaceCommunications)}
onSave={handleSave}
/>
}
@@ -80,6 +80,11 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
placeholder="support@mycompany.com"
value={handle}
onChange={setHandle}
onInputEnter={() => {
if (canSave) {
handleSave();
}
}}
disabled={loading}
/>
</Section>
@@ -1,3 +1,4 @@
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -6,40 +7,41 @@ import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { Status } from 'twenty-ui/data-display';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { type ThemeColor } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type RecordStatus = {
status: string;
statusColor: ThemeColor;
};
type DnsRecordBase = {
type DnsRecord = {
type: string;
key: string;
value: string;
priority?: number | null;
ttl?: string;
status?: string;
statusColor?: ThemeColor;
};
type DnsRecord = DnsRecordBase | (DnsRecordBase & RecordStatus);
type SettingsDnsRecordsTableProps = {
records: DnsRecord[];
};
const StyledTableRowContainer = styled.div`
> * > * {
max-width: 100%;
min-width: 0;
overflow: hidden;
}
const StyledRecordTableRow = styled(TableRow)`
margin-top: ${themeCssVariables.spacing[2]};
`;
const StyledTableCellFontWrapper = styled.div`
display: contents;
font-family: monospace;
const StyledCopyableCell = styled.div`
min-width: 0;
width: 100%;
& input {
cursor: pointer;
pointer-events: none;
}
&:hover input {
background-color: ${themeCssVariables.background.transparent.light};
border-color: ${themeCssVariables.border.color.strong};
}
`;
export const SettingsDnsRecordsTable = ({
@@ -51,91 +53,101 @@ export const SettingsDnsRecordsTable = ({
return null;
}
const hasTtlRecords = records.some((record) => isDefined(record.ttl));
const hasStatusRecords = records.some((record) => 'status' in record);
const hasPriorityRecords = records.some((record) =>
const hasPriorityColumn = records.some((record) =>
isDefined(record.priority),
);
const hasTtlColumn = records.some((record) => isDefined(record.ttl));
const hasStatusColumn = records.some((record) => isDefined(record.status));
const buildGridColumns = () => {
const baseColumns = ['max-content', '1fr', '1fr'];
if (hasPriorityRecords) baseColumns.push('max-content');
if (hasTtlRecords) baseColumns.push('max-content');
if (hasStatusRecords) baseColumns.push('max-content');
return baseColumns.join(' ');
};
const gridAutoColumns = buildGridColumns();
const gridAutoColumns = [
'100px',
'minmax(0, 1fr)',
'minmax(0, 1.5fr)',
...(hasPriorityColumn ? ['max-content'] : []),
...(hasTtlColumn ? ['max-content'] : []),
...(hasStatusColumn ? ['max-content'] : []),
].join(' ');
return (
<Table>
<StyledTableRowContainer>
<TableRow gridAutoColumns={gridAutoColumns}>
<TableHeader align="center">{t`Type`}</TableHeader>
<TableHeader align="center">{t`Key`}</TableHeader>
<TableHeader align="center">{t`Value`}</TableHeader>
{hasPriorityRecords && (
<TableHeader align="center">{t`Priority`}</TableHeader>
<TableRow gridAutoColumns={gridAutoColumns}>
<TableHeader>{t`Type`}</TableHeader>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
{hasPriorityColumn && (
<TableHeader align="center">{t`Priority`}</TableHeader>
)}
{hasTtlColumn && <TableHeader align="center">{t`TTL`}</TableHeader>}
{hasStatusColumn && (
<TableHeader align="center">{t`Status`}</TableHeader>
)}
</TableRow>
{records.map((record, index) => (
<StyledRecordTableRow
key={`${index}-${record.type}-${record.key}`}
gridAutoColumns={gridAutoColumns}
>
<TableCell>
<StyledCopyableCell
onClick={() =>
copyToClipboard(record.type, t`Copied to clipboard`)
}
>
<SettingsTextInput
instanceId={`dns-record-type-${index}`}
value={record.type}
sizeVariant="sm"
disabled
fullWidth
/>
</StyledCopyableCell>
</TableCell>
<TableCell>
<StyledCopyableCell
onClick={() =>
copyToClipboard(record.key, t`Copied to clipboard`)
}
>
<SettingsTextInput
instanceId={`dns-record-name-${index}`}
value={record.key}
sizeVariant="sm"
disabled
fullWidth
/>
</StyledCopyableCell>
</TableCell>
<TableCell>
<StyledCopyableCell
onClick={() =>
copyToClipboard(record.value, t`Copied to clipboard`)
}
>
<SettingsTextInput
instanceId={`dns-record-value-${index}`}
value={record.value}
sizeVariant="sm"
disabled
fullWidth
/>
</StyledCopyableCell>
</TableCell>
{hasPriorityColumn && (
<TableCell align="center">{record.priority}</TableCell>
)}
{hasTtlRecords && <TableHeader align="center">{t`TTL`}</TableHeader>}
{hasStatusRecords && (
<TableHeader align="center">{t`Status`}</TableHeader>
{hasTtlColumn && <TableCell align="center">{record.ttl}</TableCell>}
{hasStatusColumn && (
<TableCell align="center">
{isDefined(record.status) && isDefined(record.statusColor) && (
<Status
color={record.statusColor}
text={capitalize(record.status)}
/>
)}
</TableCell>
)}
</TableRow>
</StyledTableRowContainer>
{records.map((record) => (
<StyledTableRowContainer key={record.value}>
<TableRow gridAutoColumns={gridAutoColumns}>
<TableCell>{record.type}</TableCell>
<StyledTableCellFontWrapper>
<TableCell
overflow="hidden"
onClick={() => {
copyToClipboard(record.key || '');
}}
>
<OverflowingTextWithTooltip text={record.key} />
</TableCell>
</StyledTableCellFontWrapper>
<StyledTableCellFontWrapper>
<TableCell
overflow="hidden"
onClick={() => {
copyToClipboard(record.value);
}}
>
<OverflowingTextWithTooltip text={record.value} />
</TableCell>
</StyledTableCellFontWrapper>
{hasPriorityRecords && (
<StyledTableCellFontWrapper>
<TableCell overflow="hidden">{record.priority}</TableCell>
</StyledTableCellFontWrapper>
)}
{hasTtlRecords && (
<StyledTableCellFontWrapper>
<TableCell overflow="hidden">{record.ttl}</TableCell>
</StyledTableCellFontWrapper>
)}
{hasStatusRecords && (
<StyledTableCellFontWrapper>
<TableCell overflow="hidden">
{'status' in record ? (
<Status
color={record.statusColor}
text={capitalize(record.status)}
/>
) : null}
</TableCell>
</StyledTableCellFontWrapper>
)}
</TableRow>
</StyledTableRowContainer>
</StyledRecordTableRow>
))}
</Table>
);
@@ -6,7 +6,7 @@ import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { IconPlus } from 'twenty-ui/icon';
import { IconChevronRight, IconPlus } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -53,6 +53,7 @@ type SettingsTableListSectionProps<Item extends { id: string }> = {
items: Item[];
columns: SettingsTableListSectionColumn<Item>[];
gridAutoColumns: string;
showRowChevron?: boolean;
onRowClick?: (item: Item) => void;
footerButtonLabel: string;
onFooterButtonClick: () => void;
@@ -67,55 +68,71 @@ export const SettingsTableListSection = <
items,
columns,
gridAutoColumns,
showRowChevron = false,
onRowClick,
footerButtonLabel,
onFooterButtonClick,
}: SettingsTableListSectionProps<Item>) => (
<Section>
<H2Title
title={title}
description={description}
adornment={headerAdornment}
/>
{items.length > 0 && (
<Table>
<TableRow gridAutoColumns={gridAutoColumns}>
{columns.map((column) => (
<TableHeader
key={column.label}
align={column.align}
padding={HEADER_PADDING}
>
{column.label}
</TableHeader>
))}
</TableRow>
<StyledTableRows>
{items.map((item) => (
<StyledRowWrapper key={item.id} clickable={Boolean(onRowClick)}>
<TableRow
gridAutoColumns={gridAutoColumns}
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{columns.map((column) => (
<TableCell key={column.label} align={column.align}>
<column.Cell item={item} />
</TableCell>
))}
</TableRow>
</StyledRowWrapper>
))}
</StyledTableRows>
</Table>
)}
<StyledFooter>
<Button
Icon={IconPlus}
title={footerButtonLabel}
variant="secondary"
size="small"
onClick={onFooterButtonClick}
}: SettingsTableListSectionProps<Item>) => {
const resolvedGridAutoColumns = showRowChevron
? `${gridAutoColumns} auto`
: gridAutoColumns;
return (
<Section>
<H2Title
title={title}
description={description}
adornment={headerAdornment}
/>
</StyledFooter>
</Section>
);
{items.length > 0 && (
<Table>
<TableRow gridAutoColumns={resolvedGridAutoColumns}>
{columns.map((column) => (
<TableHeader
key={column.label}
align={column.align}
padding={HEADER_PADDING}
>
{column.label}
</TableHeader>
))}
{showRowChevron && <TableHeader padding={HEADER_PADDING} />}
</TableRow>
<StyledTableRows>
{items.map((item) => (
<StyledRowWrapper key={item.id} clickable={Boolean(onRowClick)}>
<TableRow
gridAutoColumns={resolvedGridAutoColumns}
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{columns.map((column) => (
<TableCell key={column.label} align={column.align}>
<column.Cell item={item} />
</TableCell>
))}
{showRowChevron && (
<TableCell
align="right"
color={themeCssVariables.font.color.light}
>
<IconChevronRight size={16} />
</TableCell>
)}
</TableRow>
</StyledRowWrapper>
))}
</StyledTableRows>
</Table>
)}
<StyledFooter>
<Button
Icon={IconPlus}
title={footerButtonLabel}
variant="secondary"
size="small"
onClick={onFooterButtonClick}
/>
</StyledFooter>
</Section>
);
};
@@ -28,6 +28,7 @@ import {
IconLayout,
IconMail,
IconMessage,
IconMessageCircle,
IconPlug,
IconServer,
IconSettings,
@@ -173,9 +174,9 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden: !permissionMap[PermissionFlagType.AI_SETTINGS],
},
{
label: t`Email`,
path: SettingsPath.WorkspaceEmail,
Icon: IconMail,
label: t`Communications`,
path: SettingsPath.WorkspaceCommunications,
Icon: IconMessageCircle,
isHidden:
!isEmailGroupFeatureEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
@@ -1,18 +1,14 @@
import { useLazyQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { GET_UNSUBSCRIBE_PAGE_PREVIEW_URL } from '@/settings/unsubscribe-topics/graphql/queries/getUnsubscribePagePreviewUrl';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type UnsubscribeTopicsQuery,
UnsubscribeTopicVisibility,
} from '~/generated-metadata/graphql';
import { Status } from 'twenty-ui/data-display';
import { IconExternalLink } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { Pill, Status } from 'twenty-ui/data-display';
import { IconLock } from 'twenty-ui/icon';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type UnsubscribeTopic = UnsubscribeTopicsQuery['unsubscribeTopics'][number];
@@ -21,49 +17,25 @@ export const SettingsWorkspaceUnsubscribeTopicSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { unsubscribeTopics } = useUnsubscribeTopics();
const [getPreviewUrl] = useLazyQuery<{
unsubscribePagePreviewUrl: string;
}>(GET_UNSUBSCRIBE_PAGE_PREVIEW_URL);
// Open the tab synchronously on click (so it isn't popup-blocked), then point
// it at the freshly minted preview URL once the query resolves.
const handlePreview = () => {
const previewWindow = window.open('', '_blank');
void getPreviewUrl()
.then(({ data }) => {
const url = data?.unsubscribePagePreviewUrl;
if (isDefined(previewWindow) && isDefined(url)) {
previewWindow.location.href = url;
} else {
previewWindow?.close();
}
})
.catch(() => previewWindow?.close());
};
const title = t`Unsubscribe topics`;
const description = t`Email categories recipients can opt out of`;
const organizationPill = <Pill Icon={IconLock} label={t`Organization`} />;
return (
<SettingsTableListSection<UnsubscribeTopic>
title={t`Unsubscribe Topics`}
description={t`Email categories recipients can opt out of.`}
headerAdornment={
<Button
title={t`Preview`}
variant="secondary"
size="small"
Icon={IconExternalLink}
onClick={handlePreview}
/>
}
title={title}
description={description}
headerAdornment={organizationPill}
items={unsubscribeTopics}
columns={[
{
label: t`Name`,
label: t`Topic`,
Cell: ({ item }) => <>{item.name ?? t`Untitled topic`}</>,
},
{
label: t`Visibility`,
align: 'right',
Cell: ({ item }) =>
item.visibility === UnsubscribeTopicVisibility.PUBLIC ? (
<Status color="blue" text={t`Public`} />
@@ -73,12 +45,13 @@ export const SettingsWorkspaceUnsubscribeTopicSection = () => {
},
]}
gridAutoColumns="1fr 1fr"
showRowChevron
onRowClick={(topic) =>
navigateSettings(SettingsPath.UnsubscribeTopicDetail, {
unsubscribeTopicId: topic.id,
})
}
footerButtonLabel={t`Add unsubscribe topic`}
footerButtonLabel={t`Add topic`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewUnsubscribeTopic)
}
@@ -1,23 +0,0 @@
import { styled } from '@linaria/react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledForwardingCell = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-family: monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
type SettingsWorkspaceEmailGroupForwardingCellProps = {
item: MessageChannel;
};
export const SettingsWorkspaceEmailGroupForwardingCell = ({
item,
}: SettingsWorkspaceEmailGroupForwardingCellProps) => (
<StyledForwardingCell>{item.handle}</StyledForwardingCell>
);
@@ -4,9 +4,10 @@ import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsWorkspaceEmailChannelDomainStatusCell } from '@/settings/workspace/components/SettingsWorkspaceEmailChannelDomainStatusCell';
import { SettingsWorkspaceEmailGroupForwardingCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell';
import { SettingsWorkspaceEmailGroupSourceCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
import { Pill } from 'twenty-ui/data-display';
import { IconLock } from 'twenty-ui/icon';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsWorkspaceEmailGroupSection = () => {
@@ -20,22 +21,20 @@ export const SettingsWorkspaceEmailGroupSection = () => {
return (
<SettingsTableListSection<MessageChannel>
title={t`Email Channels`}
description={t`Shared addresses your workspace uses to send and receive email.`}
title={t`Channels`}
description={t`Addresses your workspace uses to send and receive email from shared inboxes`}
headerAdornment={<Pill Icon={IconLock} label={t`Organization`} />}
items={emailGroupChannels}
columns={[
{ label: t`Source`, Cell: SettingsWorkspaceEmailGroupSourceCell },
{
label: t`Forwarding address`,
Cell: SettingsWorkspaceEmailGroupForwardingCell,
},
{ label: t`Email`, Cell: SettingsWorkspaceEmailGroupSourceCell },
{
label: t`Domain`,
align: 'right',
Cell: SettingsWorkspaceEmailChannelDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr 1fr"
gridAutoColumns="1fr 1fr"
showRowChevron
onRowClick={(channel) =>
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
messageChannelId: channel.id,
@@ -1,7 +1,6 @@
import { styled } from '@linaria/react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { IconMail } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -10,7 +9,6 @@ const StyledNameCell = styled.div`
color: ${themeCssVariables.font.color.primary};
display: flex;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
@@ -25,7 +23,6 @@ export const SettingsWorkspaceEmailGroupSourceCell = ({
return (
<StyledNameCell>
<IconMail size={16} />
<OverflowingTextWithTooltip text={sourceHandle ?? '—'} />
</StyledNameCell>
);