feat: message folders control (#14144)

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2025-08-30 21:09:10 +05:30
committed by GitHub
parent a0884cede9
commit 8ab71fef1f
49 changed files with 1786 additions and 301 deletions
@@ -1,4 +1,5 @@
import { type ImapSmtpCaldavAccount } from '@/accounts/types/ImapSmtpCaldavAccount';
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { type MessageChannelVisibility } from '~/generated/graphql';
@@ -35,6 +36,7 @@ export type MessageChannel = {
excludeNonProfessionalEmails: boolean;
excludeGroupEmails: boolean;
isSyncEnabled: boolean;
messageFolders: MessageFolder[];
visibility: MessageChannelVisibility;
syncStatus: MessageChannelSyncStatus;
syncStage: MessageChannelSyncStage;
@@ -0,0 +1,9 @@
export type MessageFolder = {
id: string;
name: string;
syncCursor: string;
isSentFolder: boolean;
isSynced: boolean;
messageChannelId: string;
__typename: 'MessageFolder';
};
@@ -47,6 +47,15 @@ export const triggerAttachRelationOptimisticEffect = ({
}
if (fieldValueIsObjectRecordConnectionWithRefs) {
const recordAlreadyExists = targetRecordFieldValue.edges.some(
(edge: RecordGqlRefEdge) =>
edge.node.__ref === sourceRecordReference.__ref,
);
if (recordAlreadyExists) {
return targetRecordFieldValue;
}
const nextEdges: RecordGqlRefEdge[] = [
...targetRecordFieldValue.edges,
{
@@ -15,6 +15,7 @@ export enum CoreObjectNameSingular {
Message = 'message',
MessageChannel = 'messageChannel',
MessageParticipant = 'messageParticipant',
MessageFolder = 'messageFolder',
MessageThread = 'messageThread',
Note = 'note',
NoteTarget = 'noteTarget',
@@ -7,12 +7,17 @@ import {
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard';
import { SettingsAccountsMessageFoldersCard } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard';
import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { type MessageChannelVisibility } from '~/generated-metadata/graphql';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { Card, Section } from 'twenty-ui/layout';
import { H2Title, IconBriefcase, IconUsers } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import {
FeatureFlagKey,
type MessageChannelVisibility,
} from '~/generated-metadata/graphql';
type SettingsAccountsMessageChannelDetailsProps = {
messageChannel: Pick<
@@ -23,6 +28,7 @@ type SettingsAccountsMessageChannelDetailsProps = {
| 'excludeNonProfessionalEmails'
| 'excludeGroupEmails'
| 'isSyncEnabled'
| 'messageFolders'
>;
};
@@ -39,6 +45,10 @@ export const SettingsAccountsMessageChannelDetails = ({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
});
const isFolderControlEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_MESSAGE_FOLDER_CONTROL_ENABLED,
);
const handleVisibilityChange = (value: MessageChannelVisibility) => {
updateOneRecord({
idToUpdate: messageChannel.id,
@@ -79,6 +89,18 @@ export const SettingsAccountsMessageChannelDetails = ({
return (
<StyledDetailsContainer>
{isFolderControlEnabled && messageChannel.messageFolders && (
<Section>
<H2Title
title={t`Folder Management`}
description={t`Control which folders are synced`}
/>
<SettingsAccountsMessageFoldersCard
messageChannelId={messageChannel.id}
messageFolders={messageChannel.messageFolders}
/>
</Section>
)}
<Section>
<H2Title
title={t`Visibility`}
@@ -4,7 +4,9 @@ import { useRecoilValue } from 'recoil';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
@@ -25,6 +27,10 @@ export const SettingsAccountsMessageChannelsContainer = () => {
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const messageChannelObjectMetadataItem = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
});
const { records: accounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
@@ -48,15 +54,16 @@ export const SettingsAccountsMessageChannelsContainer = () => {
eq: true,
},
},
recordGqlFields: generateDepthOneRecordGqlFields(
messageChannelObjectMetadataItem,
),
skip: !accounts.length,
});
const tabs = [
...messageChannels.map((messageChannel) => ({
id: messageChannel.id,
title: messageChannel.handle,
})),
];
const tabs = messageChannels.map((messageChannel) => ({
id: messageChannel.id,
title: messageChannel.handle,
}));
if (!messageChannels.length) {
return <SettingsNewAccountSection />;
@@ -2,11 +2,11 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { MessageChannelContactAutoCreationPolicy } from '@/accounts/types/MessageChannel';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { ComponentDecorator } from 'twenty-ui/testing';
import { MessageChannelVisibility } from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof SettingsAccountsMessageChannelDetails> = {
title:
@@ -26,6 +26,7 @@ const meta: Meta<typeof SettingsAccountsMessageChannelDetails> = {
excludeGroupEmails: false,
isSyncEnabled: true,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
messageFolders: [],
},
},
argTypes: {
@@ -0,0 +1,38 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
import { SettingsMessageFoldersTableHeader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTableHeader';
import { SettingsMessageFoldersTableRow } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { Section } from 'twenty-ui/layout';
type SettingsAccountsMessageFoldersCardProps = {
messageChannelId: string;
messageFolders: MessageFolder[];
};
const StyledTableRows = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAccountsMessageFoldersCard = ({
messageFolders,
}: SettingsAccountsMessageFoldersCardProps) => {
if (!messageFolders || messageFolders.length === 0) {
return <SettingsMessageFoldersEmptyStateCard />;
}
return (
<Section>
<Table>
<SettingsMessageFoldersTableHeader />
<StyledTableRows>
{messageFolders.map((folder) => (
<SettingsMessageFoldersTableRow key={folder.id} folder={folder} />
))}
</StyledTableRows>
</Table>
</Section>
);
};
@@ -0,0 +1,28 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconFolder } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
const StyledEmptyState = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(8)};
text-align: center;
`;
export const SettingsMessageFoldersEmptyStateCard = () => {
const theme = useTheme();
return (
<Section>
<StyledEmptyState>
<IconFolder size={theme.icon.size.md} />
<div>{t`No folders found for this account`}</div>
</StyledEmptyState>
</Section>
);
};
@@ -0,0 +1,19 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { Trans } from '@lingui/react/macro';
export const SettingsMessageFoldersTableHeader = () => {
return (
<Table>
<TableRow gridAutoColumns="1fr 120px">
<TableHeader>
<Trans>Folder</Trans>
</TableHeader>
<TableHeader align="center">
<Trans>Sync</Trans>
</TableHeader>
</TableRow>
</Table>
);
};
@@ -0,0 +1,75 @@
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconFolder, IconSend } from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
const StyledFolderNameCell = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledTableRow = styled(TableRow)`
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
type SettingsMessageFoldersTableRowProps = {
folder: MessageFolder;
};
export const SettingsMessageFoldersTableRow = ({
folder,
}: SettingsMessageFoldersTableRowProps) => {
const theme = useTheme();
const { updateOneRecord } = useUpdateOneRecord<MessageFolder>({
objectNameSingular: CoreObjectNameSingular.MessageFolder,
});
const handleSyncToggle = (value: boolean) => {
updateOneRecord({
idToUpdate: folder.id,
updateOneRecordInput: {
isSynced: value,
},
});
};
const formatName = (name: string) => {
return name
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
return (
<StyledTableRow gridAutoColumns="1fr 120px 70px">
<TableCell>
<StyledFolderNameCell>
{folder.isSentFolder ? (
<IconSend size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<IconFolder
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{formatName(folder.name)}
</StyledFolderNameCell>
</TableCell>
<TableCell align="center">
<Toggle
value={folder.isSynced}
onChange={handleSyncToggle}
toggleSize="small"
/>
</TableCell>
</StyledTableRow>
);
};