Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/components/mail/tray/MessageCheap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Avatar } from '@/components/avatar';

interface MessageCheapProps {
email: {
id: string;
from: {
name: string;
avatar: string;
};
subject: string;
createdAt: string;
body: string;
read: boolean;
};
active?: boolean;
selected?: boolean;
onClick: (id: string) => void;
}

export const MessageCheap = ({ email, active, selected, onClick }: MessageCheapProps) => {
const isHighlighted = active || selected;

return (
<button
onClick={() => onClick(email.id)}
className={`flex flex-col border-b border-gray-10 text-left gap-2 w-full py-3 px-5 ${isHighlighted ? 'bg-primary/10' : ''}`}
>
<div className="flex flex-row w-full gap-2">
<Avatar fullName={email.from.name} src={email.from.avatar} size={'xxs'} />
<div className="flex flex-col w-full">
<div className={`flex flex-row w-full justify-between ${isHighlighted ? 'text-primary' : ''}`}>
<div className="flex flex-row gap-1 w-full max-w-[150px] items-center">
{!email.read && <div className="h-2 w-2 rounded-full bg-primary" />}
<p className="font-semibold truncate">{email.from.name}</p>
</div>
<div>
<p className={`text-sm font-medium ${isHighlighted ? 'text-primary' : 'text-gray-50'}`}>
{email.createdAt}
</p>
</div>
</div>
<p className={`text-sm font-semibold ${isHighlighted ? 'text-primary' : ''}`}>{email.subject}</p>
<p className={`text-sm ${isHighlighted ? 'text-primary/80' : 'text-gray-50'}`}>{email.body}</p>
</div>
</div>
</button>
);
};
19 changes: 19 additions & 0 deletions src/components/mail/tray/MessageCheapSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const MessageCheapSkeleton = () => (
<div className={'flex flex-col text-left gap-2 w-full py-3 px-5 border-b border-gray-5'}>
<div className="flex flex-row w-full gap-2">
{/* Avatar */}
<div className="flex flex-col h-7 w-8 rounded-full animate-pulse bg-gray-10" />
<div className="flex flex-col gap-1 w-full">
{/* Name and date */}
<div className={'flex flex-row w-full justify-between'}>
<div className="flex rounded-md w-1/3 h-3 bg-gray-10 animate-pulse" />
<div className="flex rounded-md w-1/4 h-3 bg-gray-10 animate-pulse" />
</div>
{/* Subject */}
<div className="flex rounded-md w-1/2 h-3 bg-gray-10 animate-pulse" />
{/* Body */}
<div className="flex rounded-md w-full h-3 bg-gray-10 animate-pulse" />
</div>
</div>
</div>
);
110 changes: 110 additions & 0 deletions src/components/mail/tray/TrayList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { InfiniteScroll } from '@/components/infiniteScroll';
import { MessageCheapSkeleton } from './MessageCheapSkeleton';
import { MessageCheap } from './MessageCheap';
import { ReactNode } from 'react';

interface TrayListProps {
mails: {
id: string;
from: {
name: string;
avatar: string;
};
subject: string;
createdAt: string;
body: string;
read: boolean;
}[];
selectedEmails?: string[];
loading: boolean;
checked: boolean;
activeEmail: string;
hasMoreItems?: boolean;
emptyState?: ReactNode;
onMailSelected: (id: string) => void;
onLoadMore?: () => void;
}

/**
*
* @param {TrayListProps} TrayListProps - Props for the TrayList component
* @prop {Array} TrayListProps.mails - An array of email objects
*
* @prop {string[]} TrayListProps.selectedEmails - An array of selected email IDs
*
* @prop {boolean} TrayListProps.loading - A boolean indicating loading state
*
* @prop {boolean} TrayListProps.checked - A boolean indicating whether all emails are checked
*
* @prop {string} TrayListProps.activeEmail - The ID of the currently active email
*
* @prop {boolean} TrayListProps.hasMoreItems - A boolean indicating whether there are more items to load
*
* @prop {ReactNode} TrayListProps.emptyState - A JSX element to display when there are no emails
*
* @prop {(id: string) => void} TrayListProps.onMailSelected - A function to handle email selection
*
* @prop {() => void} TrayListProps.onLoadMore - A function to load more emails
*
* @returns {JSX.Element} The rendered TrayList component
*/

export const TrayList = ({
mails,
selectedEmails = [],
loading,
checked,
activeEmail,
hasMoreItems = false,
emptyState,
onMailSelected,
onLoadMore,
}: TrayListProps) => {
const loader = (
<div className="flex flex-col">
{new Array(3).fill(0).map((_, index) => (
<MessageCheapSkeleton key={index} />
))}
</div>
);

return (
<div className="flex flex-col w-[400px] min-w-[200px] max-w-[400px] h-screen">
<div id="tray-scroll-container" className="overflow-y-auto w-full">
{loading ? (
<>
{new Array(8).fill(0).map((_, index) => (
<div key={index} className="flex flex-col gap-2">
<MessageCheapSkeleton />
</div>
))}
</>
) : (
<>
{mails.length === 0 ? (
<>{emptyState}</>
) : (
<InfiniteScroll
handleNextPage={onLoadMore ?? (() => {})}
hasMoreItems={hasMoreItems}
loader={loader}
scrollableTarget="tray-scroll-container"
>
{mails.map((email) => (
<div key={email.id} className="flex items-center w-full flex-col">
<MessageCheap
email={email}
active={activeEmail === email.id}
selected={checked || selectedEmails.includes(email.id)}
onClick={onMailSelected}
/>
</div>
))}
</InfiniteScroll>
)}
</>
)}
</div>
</div>
);
};
94 changes: 94 additions & 0 deletions src/components/mail/tray/__test__/MessageCheap.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { MessageCheap } from '../MessageCheap';

const mockEmail = {
id: '1',
from: {
name: 'John Doe',
avatar: 'https://example.com/avatar.jpg',
},
subject: 'Test Subject',
createdAt: '2024-01-15',
body: 'This is a test email body',
read: false,
};

const mockOnClick = vi.fn();

const renderMessageCheap = (props = {}) =>
render(<MessageCheap email={mockEmail} onClick={mockOnClick} {...props} />);

describe('MessageCheap', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('should match snapshot', () => {
const messageCheap = renderMessageCheap();
expect(messageCheap).toMatchSnapshot();
});

it('should render email details correctly', () => {
renderMessageCheap();

expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText('This is a test email body')).toBeInTheDocument();
expect(screen.getByText('2024-01-15')).toBeInTheDocument();
});

it('should call onClick with email id when clicked', () => {
renderMessageCheap();

const button = screen.getByRole('button');
fireEvent.click(button);

expect(mockOnClick).toHaveBeenCalledTimes(1);
expect(mockOnClick).toHaveBeenCalledWith('1');
});

it('should show unread indicator for unread emails', () => {
const { container } = renderMessageCheap();

const unreadIndicator = container.querySelector('.bg-primary');
expect(unreadIndicator).toBeInTheDocument();
});

it('should not show unread indicator for read emails', () => {
const readEmail = { ...mockEmail, read: true };
const { container } = render(<MessageCheap email={readEmail} onClick={mockOnClick} />);

const unreadIndicator = container.querySelector('.bg-primary.h-2.w-2');
expect(unreadIndicator).not.toBeInTheDocument();
});

it('should apply highlighted styles when active', () => {
const { container } = renderMessageCheap({ active: true });

const button = container.querySelector('button');
expect(button?.className).toContain('bg-primary/10');
});

it('should apply highlighted styles when selected', () => {
const { container } = renderMessageCheap({ selected: true });

const button = container.querySelector('button');
expect(button?.className).toContain('bg-primary/10');
});

it('should not apply highlighted styles when neither active nor selected', () => {
const { container } = renderMessageCheap({ active: false, selected: false });

const button = container.querySelector('button');
expect(button?.className).not.toContain('bg-primary/10');
});

it('should render Avatar component with correct props', () => {
renderMessageCheap();

const avatarContainer = screen.getByText('John Doe').closest('.flex.flex-row');
expect(avatarContainer).toBeInTheDocument();
});
});
26 changes: 26 additions & 0 deletions src/components/mail/tray/__test__/MessageCheapSkeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MessageCheapSkeleton } from '../MessageCheapSkeleton';

describe('MessageCheapSkeleton', () => {
it('should match snapshot', () => {
const skeleton = render(<MessageCheapSkeleton />);
expect(skeleton).toMatchSnapshot();
});

it('should render skeleton structure', () => {
const { container } = render(<MessageCheapSkeleton />);

const skeletonElements = container.querySelectorAll('.animate-pulse');
expect(skeletonElements.length).toBeGreaterThan(0);
});

it('should have proper border styling', () => {
const { container } = render(<MessageCheapSkeleton />);

const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain('border-b');
expect(wrapper.className).toContain('border-gray-5');
});
});
Loading