Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(conversation)#WB-3834 draft list message preview #620

Merged
merged 9 commits into from
Mar 12, 2025
2 changes: 2 additions & 0 deletions conversation/backend/src/main/resources/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,10 @@
"progress": "Progression",
"put.trash": "Suppression du dossier",
"recipient": "Destinataire",
"recipient.list": "Liste des destinataires",
"recipient.avatar": "Avatar du destinataire",
"recipient.avatar.group": "Plusieurs destinataires",
"recipient.avatar.empty": "Aucun destinataire",
"remove.attachment": "Supprimer la pièce jointe",
"remove.from.folder": "Retirer du dossier",
"remove.link": "Supprimer",
Expand Down
42 changes: 24 additions & 18 deletions conversation/frontend/src/components/MessageRecipientList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import clsx from 'clsx';
import { Fragment, ReactNode } from 'react';
import { Recipients } from '~/models';
import { MessageRecipientListItem } from './MessageRecipientListItem';
import { useTranslation } from 'react-i18next';

export interface RecipientListProps {
recipients: Recipients;
head: ReactNode;
head?: ReactNode;
color?: 'text-gray-800' | 'text-gray-700';
truncate?: boolean;
hasLink?: boolean;
Expand All @@ -19,25 +20,30 @@ export function MessageRecipientList({
hasLink = false,
}: RecipientListProps) {
const recipientArray = [...recipients.users, ...recipients.groups];

const { t } = useTranslation('conversation');
return (
<div className={clsx({ 'text-truncate': truncate }, color)}>
<span className="text-uppercase me-4">{head}</span>
{recipientArray.map((recipient, index) => {
const type = index < recipients.users.length ? 'user' : 'group';
const isLast = index === recipientArray.length - 1;
return (
<Fragment key={recipient.id}>
<MessageRecipientListItem
recipient={recipient}
color={color}
type={type}
hasLink={hasLink}
/>
{!isLast && ', '}
</Fragment>
);
})}
{head && <span className="text-uppercase me-4">{head}</span>}
<ul
className={'list-unstyled mb-0 d-inline'}
aria-label={t('recipient.list')}
>
{recipientArray.map((recipient, index) => {
const type = index < recipients.users.length ? 'user' : 'group';
const isLast = index === recipientArray.length - 1;
return (
<li key={recipient.id} className="d-inline">
<MessageRecipientListItem
recipient={recipient}
color={color}
type={type}
hasLink={hasLink}
/>
{!isLast && ', '}
</li>
);
})}
</ul>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Avatar } from '@edifice.io/react';
import { IconGroupAvatar } from '@edifice.io/react/icons';
import { IconGroupAvatar, IconQuestionMark } from '@edifice.io/react/icons';
import { useTranslation } from 'react-i18next';
import { Recipients } from '~/models';
import { useRecipientAvatar } from '../../../../hooks/useRecipientAvatar';
Expand All @@ -12,7 +12,17 @@ export function RecipientAvatar({ recipients }: RecipientAvatarProps) {
const { t } = useTranslation('conversation');
const { recipientCount, url } = useRecipientAvatar(recipients);

if (recipientCount > 1) {
if (recipientCount === 0) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tu peux refacto pour éviter la répétition et virer le else inutile :

if (recipientCount === 1 && url) {
  return <Avatar
      alt={t('recipient.avatar')}
      size="sm"
      src={url}
      variant="circle"
    />;
}

const avatars = {
  0: {
    bg: 'bg-yellow-200',
    label: t('recipient.avatar.empty'),
    icon: <IconQuestionMark className="w-16" />
  },
  1: {
    bg: 'bg-orange-200',
    label: t('recipient.avatar.group'),
    icon: <IconGroupAvatar className="w-16" />
  },
};

const avatar = avatars[Math.min(recipientCount, 1)];

return avatar ? (
  <div
    className={`${avatar.bg} avatar avatar-sm rounded-circle`}
    aria-label={avatar.label}
    role="img"
  >
    {avatar.icon}
  </div>
) : null;

return (
<div
className="bg-yellow-200 avatar avatar-sm rounded-circle"
aria-label={t('recipient.avatar.empty')}
role="img"
>
<IconQuestionMark className="w-16" />
</div>
);
} else if (recipientCount > 1) {
return (
<div
className="bg-orange-200 avatar avatar-sm rounded-circle"
Expand All @@ -31,8 +41,6 @@ export function RecipientAvatar({ recipients }: RecipientAvatarProps) {
variant="circle"
/>
);
} else {
return null;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useTranslation } from 'react-i18next';
import { ReactNode } from 'react';
import { MessageRecipientList } from '~/components/MessageRecipientList';
import { MessageMetadata } from '~/models';

export interface RecipientListPreviewProps {
message: MessageMetadata;
head?: ReactNode;
}

export function RecipientListPreview({ message }: RecipientListPreviewProps) {
const { t } = useTranslation('conversation');
export function RecipientListPreview({
message,
head,
}: RecipientListPreviewProps) {
const to = message.to;
const cc = message.cc;
const cci = message.cci ?? { users: [], groups: [] };
Expand All @@ -17,7 +20,7 @@ export function RecipientListPreview({ message }: RecipientListPreviewProps) {
};
return (
<MessageRecipientList
head={t('at')}
head={head}
recipients={recipients}
color="text-gray-800"
truncate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe('Message preview header component', () => {
expect(messageHasAttachements).not.toBeInTheDocument();
});

it('should display "to" label and recipient name when is in outbox', async () => {
it('should display "to" label and recipient name when in outbox', async () => {
mocks.useParams.mockReturnValue({ folderId: 'outbox' });

render(<MessagePreview message={message} />);
Expand All @@ -119,7 +119,7 @@ describe('Message preview header component', () => {
expect(senderName).toBeInTheDocument();
});

it('should display the recipient avatar when is in outbox', async () => {
it('should display the recipient avatar when in outbox', async () => {
mocks.useParams.mockReturnValue({ folderId: 'outbox' });

const messageWithOneRecipient = mockMessagesOfInbox[1];
Expand All @@ -131,7 +131,7 @@ describe('Message preview header component', () => {
expect(recipientAvatar).toBeInTheDocument();
});

it('should display group avatar icon when more than one recipient in outbox', async () => {
it('should display group avatar icon when more than one recipient when in outbox', async () => {
mocks.useParams.mockReturnValue({ folderId: 'outbox' });
render(<MessagePreview message={message} />);

Expand All @@ -141,13 +141,43 @@ describe('Message preview header component', () => {
expect(recipientAvatar).toBeInTheDocument();
});

it('should display all recipients in after "to" label in outbox', async () => {
it('should display all recipients after "to" label when in outbox', async () => {
mocks.useParams.mockReturnValue({ folderId: 'outbox' });
render(<MessagePreview message={mockMessageOfOutbox} />);

const atElement = screen.getByText('at');
const parentContainer = atElement.closest('div');
const spanElements = parentContainer?.querySelectorAll('span');
expect(spanElements).toHaveLength(6);
screen.getByText('at');

const recipientItems = screen.getAllByRole('listitem');
expect(recipientItems).toHaveLength(5);
});

it('should display a "draft" label when in draft', async () => {
mocks.useParams.mockReturnValue({ folderId: 'draft' });
render(<MessagePreview message={mockMessageOfOutbox} />);
screen.getByText('draft');
});

it('should display all recipients without "to" label when in draft', async () => {
mocks.useParams.mockReturnValue({ folderId: 'draft' });
render(<MessagePreview message={mockMessageOfOutbox} />);

const atElement = screen.queryByText('at');
expect(atElement).toBeNull();

const recipientItems = screen.queryAllByRole('listitem');
expect(recipientItems).toHaveLength(5);
});

it.only('should not display any recipients if there are none when in draft', async () => {
mocks.useParams.mockReturnValue({ folderId: 'draft' });
const message = { ...mockMessageOfOutbox };
message.to = { users: [], groups: [] };
message.cc = { users: [], groups: [] };
message.cci = { users: [], groups: [] };

render(<MessagePreview message={message} />);

const recipientItems = screen.queryAllByRole('listitem');
expect(recipientItems).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useDate } from '@edifice.io/react';
import { IconPaperclip, IconUndo } from '@edifice.io/react/icons';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useSelectedFolder } from '~/hooks';
import { MessageMetadata } from '~/models';
import { useMessageUserDisplayName } from '../../../hooks/useUserDisplayName';
import RecipientAvatar from './components/RecipientAvatar';
Expand All @@ -14,7 +14,7 @@ export interface MessagePreviewProps {

export function MessagePreview({ message }: MessagePreviewProps) {
const { t } = useTranslation('conversation');
const { folderId } = useParams<{ folderId: string }>();
const { folderId } = useSelectedFolder();
const { fromNow } = useDate();
const senderDisplayName = useMessageUserDisplayName(message.from);

Expand All @@ -24,32 +24,38 @@ export function MessagePreview({ message }: MessagePreviewProps) {
<IconUndo className="gray-800" title="message-response" />
)}

{'outbox' === folderId ? (
{folderId === 'outbox' || folderId === 'draft' ? (
<RecipientAvatar recipients={message.to} />
) : (
<SenderAvatar authorId={message.from.id} />
)}

<div className="d-flex flex-fill flex-column overflow-hidden">
<div className="d-flex flex-fill justify-content-between overflow-hidden">
<div className="d-flex flex-fill justify-content-between overflow-hidden gap-4">
{folderId === 'draft' && (
<span className="text-danger fw-bold">{t('draft')}</span>
)}
<div className="text-truncate flex-fill">
{'outbox' === folderId ? (
<RecipientListPreview message={message} />
) : (
senderDisplayName
{folderId === 'draft' && <RecipientListPreview message={message} />}
{folderId === 'outbox' && (
<RecipientListPreview message={message} head={t('at')} />
)}
{(folderId === 'inbox' || folderId === 'trash') && (
<span>{senderDisplayName}</span>
)}
</div>

<div className="fw-bold text-nowrap fs-12 gray-800">
{fromNow(message.date)}
<span>{fromNow(message.date)}</span>
</div>
</div>
<div className="d-flex flex-fill justify-content-between overflow-hidden">
{message.subject ? (
<div className="text-truncate flex-fill">{message.subject}</div>
<span className="text-truncate flex-fill">{message.subject}</span>
) : (
<div className="text-truncate flex-fill text-gray-700">
<span className="text-truncate flex-fill text-gray-700">
{t('nosubject')}
</div>
</span>
)}
{message.hasAttachment && (
<IconPaperclip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ describe('Message recipient list', () => {
});

it('should display recipient list for To but not Cc and Cci', async () => {
mockFullMessage.cc = { users: [], groups: [] };
render(<MessageHeader message={mockFullMessage} />);
const message = { ...mockFullMessage };
message.cc = { users: [], groups: [] };
render(<MessageHeader message={message} />);

const toLabel = screen.queryByText('at');
expect(toLabel).toBeInTheDocument();
Expand Down
5 changes: 3 additions & 2 deletions conversation/frontend/src/hooks/useRecipientAvatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { Recipients } from '~/models';
export function useRecipientAvatar(recipients: Recipients) {
const { getAvatarURL } = useDirectory();
const recipientCount = recipients.users.length + recipients.groups.length;

if (recipientCount > 1) {
if (recipientCount === 0) {
return { recipientCount, url: null };
} else if (recipientCount > 1) {
return { recipientCount, url: null };
} else {
const firstRecipient = recipients.users[0] || recipients.groups[0];
Expand Down
4 changes: 2 additions & 2 deletions conversation/frontend/src/hooks/useSelectedFolder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
import { Folder } from '~/models';
import { Folder, SystemFolder } from '~/models';
import { searchFolder, useFoldersTree } from '~/services';

/**
Expand All @@ -11,7 +11,7 @@ import { searchFolder, useFoldersTree } from '~/services';
* }
*/
export function useSelectedFolder(): {
folderId?: string;
folderId?: SystemFolder;
userFolder?: Folder;
} {
const { folderId } = useParams() as { folderId: string };
Expand Down
Loading