Skip to content
Draft
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
72 changes: 72 additions & 0 deletions components/EmailLoginForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EmailLoginForm } from './EmailLoginForm';

// Create a mock toast function
const mockToast = vi.fn();

// Mock the use-toast hook
vi.mock('./ui/use-toast', () => ({
useToast: () => ({
toast: mockToast,
}),
}));

describe('EmailLoginForm', () => {
const user = userEvent.setup();

afterEach(() => {
mockToast.mockClear();
});

it('renders email input and login button', () => {
const mockLogin = vi.fn();
render(<EmailLoginForm onLogin={mockLogin} />);

const emailInput = screen.getByLabelText('Email');
const loginButton = screen.getByRole('button', { name: /login/i });

expect(emailInput).toBeTruthy();
expect(loginButton).toBeTruthy();
});

it('validates email on form submission', async () => {
const mockLogin = vi.fn();
render(<EmailLoginForm onLogin={mockLogin} />);

const emailInput = screen.getByLabelText('Email');
const loginButton = screen.getByRole('button', { name: /login/i });

// Invalid email
await act(async () => {
await user.type(emailInput, 'invalid-email');
await user.click(loginButton);
});

// Check toast was called with error
expect(mockToast).toHaveBeenCalledWith({
title: 'Invalid Email',
description: 'Please enter a valid email address.',
variant: 'destructive',
});
expect(mockLogin).not.toHaveBeenCalled();
});

it('calls onLogin with valid email', async () => {
const mockLogin = vi.fn(async () => {});
render(<EmailLoginForm onLogin={mockLogin} />);

const emailInput = screen.getByLabelText('Email');
const loginButton = screen.getByRole('button', { name: /login/i });

// Valid email
await act(async () => {
await user.type(emailInput, '[email protected]');
await user.click(loginButton);
});

expect(mockLogin).toHaveBeenCalledWith('[email protected]');
expect(mockToast).not.toHaveBeenCalled();
});
});
70 changes: 70 additions & 0 deletions components/EmailLoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { useToast } from './ui/use-toast';

interface EmailLoginFormProps {
onLogin: (email: string) => Promise<void>;
}

export const EmailLoginForm: React.FC<EmailLoginFormProps> = ({ onLogin }) => {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();

const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!validateEmail(email)) {
toast({
title: 'Invalid Email',
description: 'Please enter a valid email address.',
variant: 'destructive',
});
return;
}

try {
setIsLoading(true);
await onLogin(email);
} catch (error) {
toast({
title: 'Login Error',
description: error instanceof Error ? error.message : 'An unexpected error occurred',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4" data-testid="email-login-form">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
aria-label="Email"
/>
</div>
<Button
type="submit"
disabled={isLoading}
className="w-full"
>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
);
};
203 changes: 21 additions & 182 deletions components/ui/use-toast.ts
Original file line number Diff line number Diff line change
@@ -1,191 +1,30 @@
"use client";
import { useState } from 'react';

// Inspired by react-hot-toast library
import * as React from "react";
type ToastVariant = 'default' | 'destructive';

import type { ToastActionElement, ToastProps } from "@/components/ui/toast";

const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;

type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};

const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;

let count = 0;

function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}

type ActionType = typeof actionTypes;

type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};

interface State {
toasts: ToasterToast[];
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();

const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}

const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId,
});
}, TOAST_REMOVE_DELAY);

toastTimeouts.set(toastId, timeout);
};

export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};

case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};

case "DISMISS_TOAST": {
const { toastId } = action;

// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}

return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};

const listeners: Array<(state: State) => void> = [];

let memoryState: State = { toasts: [] };

function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
interface ToastOptions {
title?: string;
description?: string;
action?: any;
variant?: ToastVariant;
}

type Toast = Omit<ToasterToast, "id">;

function toast({ ...props }: Toast) {
const id = genId();
export function useToast() {
const [toasts, setToasts] = useState<ToastOptions[]>([]);

const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });

dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});

return {
id,
dismiss,
update,
const toast = (options: ToastOptions) => {
setToasts((prevToasts) => [...prevToasts, options]);
};
}

function useToast() {
const [state, setState] = React.useState<State>(memoryState);

React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);

return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
const dismiss = (toastId: string) => {
setToasts((prevToasts) =>
prevToasts.filter((toast) => toast.title !== toastId)
);
};
}

export { useToast, toast };
return {
toast,
dismiss,
toasts
};
}
Loading