Skip to content

Commit 3d324fa

Browse files
authored
Merge pull request #84 from internxt/feature/emails-list
[PB-5979]: feature/emails list
2 parents 5663da1 + 8f893ba commit 3d324fa

17 files changed

+1389
-6
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@internxt/ui",
3-
"version": "0.1.9",
3+
"version": "0.1.10",
44
"description": "Library of Internxt components",
55
"repository": {
66
"type": "git",

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ export * from './table/Table';
2828
export * from './textArea';
2929
export * from './tooltip';
3030
export * from './sidenav';
31+
export * from './mail';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Avatar } from '@/components/avatar';
2+
3+
export interface MessageCheapProps {
4+
email: {
5+
id: string;
6+
from: {
7+
name: string;
8+
avatar: string;
9+
};
10+
subject: string;
11+
createdAt: string;
12+
body: string;
13+
read: boolean;
14+
};
15+
active?: boolean;
16+
selected?: boolean;
17+
onClick: (id: string) => void;
18+
}
19+
20+
const MessageCheap = ({ email, active, selected, onClick }: MessageCheapProps) => {
21+
const isHighlighted = active || selected;
22+
23+
return (
24+
<button
25+
onClick={() => onClick(email.id)}
26+
className={`flex flex-col border-b border-gray-10 text-left gap-2 w-full py-3 px-5 ${isHighlighted ? 'bg-primary/10' : ''}`}
27+
>
28+
<div className="flex flex-row w-full gap-2">
29+
<Avatar fullName={email.from.name} src={email.from.avatar} size={'xxs'} />
30+
<div className="flex flex-col w-full">
31+
<div className={`flex flex-row w-full justify-between ${isHighlighted ? 'text-primary' : ''}`}>
32+
<div className="flex flex-row gap-1 w-full max-w-[150px] items-center">
33+
{!email.read && <div className="h-2 w-2 rounded-full bg-primary" />}
34+
<p className="font-semibold truncate">{email.from.name}</p>
35+
</div>
36+
<div>
37+
<p className={`text-sm font-medium ${isHighlighted ? 'text-primary' : 'text-gray-50'}`}>
38+
{email.createdAt}
39+
</p>
40+
</div>
41+
</div>
42+
<p className={`text-sm font-semibold ${isHighlighted ? 'text-primary' : ''}`}>{email.subject}</p>
43+
<p className={`text-sm ${isHighlighted ? 'text-primary/80' : 'text-gray-50'}`}>{email.body}</p>
44+
</div>
45+
</div>
46+
</button>
47+
);
48+
};
49+
50+
export default MessageCheap;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const MessageCheapSkeleton = () => (
2+
<div className={'flex flex-col text-left gap-2 w-full py-3 px-5 border-b border-gray-5'}>
3+
<div className="flex flex-row w-full gap-2">
4+
{/* Avatar */}
5+
<div className="flex flex-col h-7 w-8 rounded-full animate-pulse bg-gray-10" />
6+
<div className="flex flex-col gap-1 w-full">
7+
{/* Name and date */}
8+
<div className={'flex flex-row w-full justify-between'}>
9+
<div className="flex rounded-md w-1/3 h-3 bg-gray-10 animate-pulse" />
10+
<div className="flex rounded-md w-1/4 h-3 bg-gray-10 animate-pulse" />
11+
</div>
12+
{/* Subject */}
13+
<div className="flex rounded-md w-1/2 h-3 bg-gray-10 animate-pulse" />
14+
{/* Body */}
15+
<div className="flex rounded-md w-full h-3 bg-gray-10 animate-pulse" />
16+
</div>
17+
</div>
18+
</div>
19+
);
20+
21+
export default MessageCheapSkeleton;

src/components/mail/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Tray list
2+
export { default as Tray } from './tray/TrayList';
3+
export { default as MessageCheap } from './cheaps/MessageCheap';
4+
export { default as MessageCheapSkeleton } from './cheaps/MessageCheapSkeleton';
5+
6+
export type { TrayListProps } from './tray/TrayList';
7+
export type { MessageCheapProps } from './cheaps/MessageCheap';
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { InfiniteScroll } from '@/components/infiniteScroll';
2+
import MessageCheapSkeleton from '../cheaps/MessageCheapSkeleton';
3+
import MessageCheap from '../cheaps/MessageCheap';
4+
import { ReactNode } from 'react';
5+
6+
export interface TrayListProps {
7+
mails: {
8+
id: string;
9+
from: {
10+
name: string;
11+
avatar: string;
12+
};
13+
subject: string;
14+
createdAt: string;
15+
body: string;
16+
read: boolean;
17+
}[];
18+
selectedEmails?: string[];
19+
loading: boolean;
20+
checked?: boolean;
21+
activeEmail?: string;
22+
hasMoreItems?: boolean;
23+
emptyState?: ReactNode;
24+
onMailSelected?: (id: string) => void;
25+
onLoadMore?: () => void;
26+
}
27+
28+
/**
29+
*
30+
* @param {TrayListProps} TrayListProps - Props for the TrayList component
31+
* @prop {Array} TrayListProps.mails - An array of email objects
32+
*
33+
* @prop {string[]} TrayListProps.selectedEmails - An array of selected email IDs
34+
*
35+
* @prop {boolean} TrayListProps.loading - A boolean indicating loading state
36+
*
37+
* @prop {boolean} TrayListProps.checked - A boolean indicating whether all emails are checked
38+
*
39+
* @prop {string} TrayListProps.activeEmail - The ID of the currently active email
40+
*
41+
* @prop {boolean} TrayListProps.hasMoreItems - A boolean indicating whether there are more items to load
42+
*
43+
* @prop {ReactNode} TrayListProps.emptyState - A JSX element to display when there are no emails
44+
*
45+
* @prop {(id: string) => void} TrayListProps.onMailSelected - A function to handle email selection
46+
*
47+
* @prop {() => void} TrayListProps.onLoadMore - A function to load more emails
48+
*
49+
* @returns {JSX.Element} The rendered TrayList component
50+
*/
51+
52+
const TrayList = ({
53+
mails,
54+
selectedEmails = [],
55+
loading,
56+
checked,
57+
activeEmail,
58+
hasMoreItems = false,
59+
emptyState,
60+
onMailSelected = () => {},
61+
onLoadMore = () => {},
62+
}: TrayListProps) => {
63+
const loader = (
64+
<div className="flex flex-col">
65+
{new Array(3).fill(0).map((_, index) => (
66+
<MessageCheapSkeleton key={index} />
67+
))}
68+
</div>
69+
);
70+
71+
return (
72+
<div className="flex flex-col w-[400px] min-w-[200px] max-w-[400px] h-full">
73+
<div id="tray-scroll-container" className="overflow-y-auto w-full h-full min-h-0">
74+
{loading ? (
75+
<>
76+
{new Array(8).fill(0).map((_, index) => (
77+
<div key={index} className="flex flex-col gap-2">
78+
<MessageCheapSkeleton />
79+
</div>
80+
))}
81+
</>
82+
) : (
83+
<>
84+
{mails.length === 0 ? (
85+
<>{emptyState}</>
86+
) : (
87+
<InfiniteScroll
88+
handleNextPage={onLoadMore}
89+
hasMoreItems={hasMoreItems}
90+
loader={loader}
91+
scrollableTarget="tray-scroll-container"
92+
>
93+
{mails.map((email) => (
94+
<div key={email.id} className="flex items-center w-full flex-col">
95+
<MessageCheap
96+
email={email}
97+
active={activeEmail === email.id}
98+
selected={checked || selectedEmails.includes(email.id)}
99+
onClick={onMailSelected}
100+
/>
101+
</div>
102+
))}
103+
</InfiniteScroll>
104+
)}
105+
</>
106+
)}
107+
</div>
108+
</div>
109+
);
110+
};
111+
112+
export default TrayList;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { render, fireEvent, screen } from '@testing-library/react';
3+
import { describe, it, expect, vi, afterEach } from 'vitest';
4+
import MessageCheap from '../../cheaps/MessageCheap';
5+
6+
const mockEmail = {
7+
id: '1',
8+
from: {
9+
name: 'John Doe',
10+
avatar: 'https://example.com/avatar.jpg',
11+
},
12+
subject: 'Test Subject',
13+
createdAt: '2024-01-15',
14+
body: 'This is a test email body',
15+
read: false,
16+
};
17+
18+
const mockOnClick = vi.fn();
19+
20+
const renderMessageCheap = (props = {}) =>
21+
render(<MessageCheap email={mockEmail} onClick={mockOnClick} {...props} />);
22+
23+
describe('MessageCheap', () => {
24+
afterEach(() => {
25+
vi.clearAllMocks();
26+
});
27+
28+
it('should match snapshot', () => {
29+
const messageCheap = renderMessageCheap();
30+
expect(messageCheap).toMatchSnapshot();
31+
});
32+
33+
it('should render email details correctly', () => {
34+
renderMessageCheap();
35+
36+
expect(screen.getByText('John Doe')).toBeInTheDocument();
37+
expect(screen.getByText('Test Subject')).toBeInTheDocument();
38+
expect(screen.getByText('This is a test email body')).toBeInTheDocument();
39+
expect(screen.getByText('2024-01-15')).toBeInTheDocument();
40+
});
41+
42+
it('should call onClick with email id when clicked', () => {
43+
renderMessageCheap();
44+
45+
const button = screen.getByRole('button');
46+
fireEvent.click(button);
47+
48+
expect(mockOnClick).toHaveBeenCalledTimes(1);
49+
expect(mockOnClick).toHaveBeenCalledWith('1');
50+
});
51+
52+
it('should show unread indicator for unread emails', () => {
53+
const { container } = renderMessageCheap();
54+
55+
const unreadIndicator = container.querySelector('.bg-primary');
56+
expect(unreadIndicator).toBeInTheDocument();
57+
});
58+
59+
it('should not show unread indicator for read emails', () => {
60+
const readEmail = { ...mockEmail, read: true };
61+
const { container } = render(<MessageCheap email={readEmail} onClick={mockOnClick} />);
62+
63+
const unreadIndicator = container.querySelector('.bg-primary.h-2.w-2');
64+
expect(unreadIndicator).not.toBeInTheDocument();
65+
});
66+
67+
it('should apply highlighted styles when active', () => {
68+
const { container } = renderMessageCheap({ active: true });
69+
70+
const button = container.querySelector('button');
71+
expect(button?.className).toContain('bg-primary/10');
72+
});
73+
74+
it('should apply highlighted styles when selected', () => {
75+
const { container } = renderMessageCheap({ selected: true });
76+
77+
const button = container.querySelector('button');
78+
expect(button?.className).toContain('bg-primary/10');
79+
});
80+
81+
it('should not apply highlighted styles when neither active nor selected', () => {
82+
const { container } = renderMessageCheap({ active: false, selected: false });
83+
84+
const button = container.querySelector('button');
85+
expect(button?.className).not.toContain('bg-primary/10');
86+
});
87+
88+
it('should render Avatar component with correct props', () => {
89+
renderMessageCheap();
90+
91+
const avatarContainer = screen.getByText('John Doe').closest('.flex.flex-row');
92+
expect(avatarContainer).toBeInTheDocument();
93+
});
94+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react';
3+
import { describe, it, expect } from 'vitest';
4+
import MessageCheapSkeleton from '../../cheaps/MessageCheapSkeleton';
5+
6+
describe('MessageCheapSkeleton', () => {
7+
it('should match snapshot', () => {
8+
const skeleton = render(<MessageCheapSkeleton />);
9+
expect(skeleton).toMatchSnapshot();
10+
});
11+
12+
it('should render skeleton structure', () => {
13+
const { container } = render(<MessageCheapSkeleton />);
14+
15+
const skeletonElements = container.querySelectorAll('.animate-pulse');
16+
expect(skeletonElements.length).toBeGreaterThan(0);
17+
});
18+
19+
it('should have proper border styling', () => {
20+
const { container } = render(<MessageCheapSkeleton />);
21+
22+
const wrapper = container.firstChild as HTMLElement;
23+
expect(wrapper.className).toContain('border-b');
24+
expect(wrapper.className).toContain('border-gray-5');
25+
});
26+
});

0 commit comments

Comments
 (0)