Skip to content

Commit

Permalink
Add Logout Functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
ThomasCode92 authored Jan 13, 2025
1 parent 510ebe8 commit 78bfdc6
Show file tree
Hide file tree
Showing 16 changed files with 198 additions and 26 deletions.
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import tailwind from "eslint-plugin-tailwindcss";
import testingLibrary from "eslint-plugin-testing-library";

export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ["dist", "tests/coverage"] },
{
extends: [
js.configs.recommended,
Expand Down
44 changes: 41 additions & 3 deletions src/components/NavigationBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { User } from "firebase/auth";
import { MemoryRouter } from "react-router-dom";

import { UserContext } from "@/contexts/userContext";
import { signOutUser } from "@/utils/firebase";
import NavigationBar from "./NavigationBar";

const currentUser = { displayName: "Alice" } as User;
const setCurrentUser = vi.fn();

function setup(currentUser: User | null) {
const value = { currentUser, setCurrentUser };

render(
<UserContext.Provider value={value}>
<NavigationBar />
</UserContext.Provider>,
{ wrapper: MemoryRouter },
);

return userEvent.setup();
}

vi.mock("firebase/auth");
vi.mock("@/utils/firebase");

test("should render a logo", function () {
render(<NavigationBar />, { wrapper: MemoryRouter });
setup(currentUser);
const logoElement = screen.getByTestId("crown-logo");
expect(logoElement).toBeInTheDocument();
});

test("should have a link to the shop page", function () {
render(<NavigationBar />, { wrapper: MemoryRouter });
setup(currentUser);
const shopLinkElement = screen.getByText(/shop/i);
expect(shopLinkElement).toHaveAttribute("href", "/shop");
});

test("should have a link to the authentication page", function () {
render(<NavigationBar />, { wrapper: MemoryRouter });
setup(null);
const signInLinkElement = screen.getByText(/sign in/i);
expect(signInLinkElement).toHaveAttribute("href", "/auth");
});

test("should show the sign out link when a user is signed in", function () {
setup(currentUser);
const signOutLinkElement = screen.getByText(/sign out/i);
expect(signOutLinkElement).toBeInTheDocument();
});

test("should call the sign out function when the sign out link is clicked", async function () {
const { click } = setup(currentUser);
const signOutLinkElement = screen.getByText(/sign out/i);
await click(signOutLinkElement);

expect(signOutUser).toHaveBeenCalled();
expect(setCurrentUser).toHaveBeenCalledWith(null);
});
18 changes: 17 additions & 1 deletion src/components/NavigationBar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { useContext } from "react";
import { Link } from "react-router-dom";

import { UserContext } from "@/contexts/userContext";
import { signOutUser } from "@/utils/firebase";

import CrownLogo from "@/assets/crown.svg";

export default function NavigationBar() {
const { currentUser, setCurrentUser } = useContext(UserContext);

async function handleSignOut() {
await signOutUser();
setCurrentUser(null);
}

return (
<nav className="mx-6 mb-6 flex h-16 items-center justify-between">
<Link className="flex flex-col justify-around" to="/">
Expand All @@ -13,7 +24,12 @@ export default function NavigationBar() {
<Link to="/shop">shop</Link>
</li>
<li className="text-lg uppercase">
<Link to="/auth">sign in</Link>
{currentUser && (
<span className="cursor-pointer" onClick={handleSignOut}>
sign out
</span>
)}
{!currentUser && <Link to="/auth">sign in</Link>}
</li>
</ul>
</nav>
Expand Down
2 changes: 1 addition & 1 deletion src/components/UI/FormInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test("should apply default classes when input is empty", () => {
test("should apply password classes when input type is password", () => {
render(<FormInput label="Password" type="password" />);
const inputElement = screen.getByLabelText("Password");
expect(inputElement).toHaveClass("tracking-wider");
expect(inputElement).toHaveClass("tracking-widest");
});

test("should focus input when label is clicked", async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/UI/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function FormInput({ label, ...otherProps }: FormInputProps) {
? tw`-top-5 text-xs text-black`
: tw`top-3 text-gray-600`;
const passwordClasses =
otherProps.type === "password" ? "tracking-wider" : "";
otherProps.type === "password" ? "tracking-widest" : "";

const inputClasses = tw`peer my-6 block w-full border-b-2 border-b-gray-600 bg-slate-100 py-2.5 pl-1 pr-2.5 text-lg text-gray-600 focus:outline-none ${passwordClasses}`;
const labelClasses = tw`pointer-events-none absolute left-1 transition-all peer-focus:-top-5 peer-focus:text-xs peer-focus:text-black ${shrinkClasses}`;
Expand Down
33 changes: 27 additions & 6 deletions src/components/authentication/SignInForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { User } from "firebase/auth";

import { UserContext } from "@/contexts/userContext";

import SignInForm from "./SignInForm";

Expand Down Expand Up @@ -31,6 +34,22 @@ vi.mock("@/utils/firebase", async function () {

vi.spyOn(window, "alert").mockImplementation(() => {});

const authUser = { displayName: "Alice" } as User;
const setCurrentUser = vi.fn();

function setup() {
const user = userEvent.setup();
const ctx = { currentUser: null, setCurrentUser };

render(
<UserContext.Provider value={ctx}>
<SignInForm />
</UserContext.Provider>,
);

return user;
}

beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -61,22 +80,24 @@ test("should render the correct buttons", function () {
});

test("should submit the form with the correct data", async function () {
render(<SignInForm />);
mocks.signInAuthUserFn.mockResolvedValueOnce({ user: authUser });
const { type, click } = setup();

const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /^sign in$/i });

const user = userEvent.setup();

await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password");
await user.click(submitButton);
await type(emailInput, "[email protected]");
await type(passwordInput, "password");
await click(submitButton);

expect(mocks.signInAuthUserFn).toHaveBeenCalledWith(
"[email protected]",
"password",
);

expect(setCurrentUser).toHaveBeenCalledOnce();
expect(setCurrentUser).toHaveBeenCalledWith(authUser);
});

test("should show an alert if the password is incorrect", async function () {
Expand Down
7 changes: 5 additions & 2 deletions src/components/authentication/SignInForm.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { AuthError, getRedirectResult } from "firebase/auth";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { ChangeEvent, FormEvent, useContext, useEffect, useState } from "react";

import Button from "@/components/UI/Button";
import FormInput from "@/components/UI/FormInput";

import { UserContext } from "@/contexts/userContext";
import {
auth,
createUserDocumentFromAuth,
Expand All @@ -23,6 +24,7 @@ interface SignInFormProps {

export default function SignInForm({ useRedirect = false }: SignInFormProps) {
const [formFields, setFormFields] = useState(INITIAL_FORM_FIELDS);
const { setCurrentUser } = useContext(UserContext);

useEffect(() => {
async function handleRedirectResult() {
Expand All @@ -42,10 +44,11 @@ export default function SignInForm({ useRedirect = false }: SignInFormProps) {
event.preventDefault();

try {
await signInAuthUserWithEmailAndPassword(
const { user } = await signInAuthUserWithEmailAndPassword(
formFields.email,
formFields.password,
);
setCurrentUser(user);
} catch (error) {
if (
(error as AuthError).code === "auth/wrong-password" ||
Expand Down
34 changes: 26 additions & 8 deletions src/components/authentication/SignUpForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { UserContext } from "@/contexts/userContext";

import SignUpForm from "./SignUpForm";

const mockedMethods = vi.hoisted(function () {
Expand All @@ -23,6 +25,21 @@ mockedMethods.createAuthUserFn.mockResolvedValue({

vi.spyOn(window, "alert").mockImplementation(() => {});

const setCurrentUser = vi.fn();

function setup() {
const user = userEvent.setup();
const ctx = { currentUser: null, setCurrentUser };

render(
<UserContext.Provider value={ctx}>
<SignUpForm />
</UserContext.Provider>,
);

return user;
}

beforeEach(function () {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -52,21 +69,19 @@ test("should render the correct buttons", function () {
});

test("should submit the form with the correct data", async function () {
render(<SignUpForm />);
const { type, click } = setup();

const displayNameInput = screen.getByLabelText(/display name/i);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/^password/i);
const confirmPasswordInput = screen.getByLabelText(/^confirm password/i);
const submitButton = screen.getByRole("button", { name: /sign up/i });

const user = userEvent.setup();

await user.type(displayNameInput, "John Doe");
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password");
await user.type(confirmPasswordInput, "password");
await user.click(submitButton);
await type(displayNameInput, "John Doe");
await type(emailInput, "[email protected]");
await type(passwordInput, "password");
await type(confirmPasswordInput, "password");
await click(submitButton);

expect(mockedMethods.createAuthUserFn).toHaveBeenCalledWith(
"[email protected]",
Expand All @@ -77,6 +92,9 @@ test("should submit the form with the correct data", async function () {
{ uid: "123" },
{ displayName: "John Doe" },
);

expect(setCurrentUser).toHaveBeenCalledOnce();
expect(setCurrentUser).toHaveBeenCalledWith({ uid: "123" });
});

test("should show an alert if passwords do not match", async function () {
Expand Down
6 changes: 5 additions & 1 deletion src/components/authentication/SignUpForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AuthError } from "firebase/auth";
import { ChangeEvent, FormEvent, useState } from "react";
import { ChangeEvent, FormEvent, useContext, useState } from "react";

import Button from "@/components/UI/Button";
import FormInput from "@/components/UI/FormInput";
import { UserContext } from "@/contexts/userContext";

import {
createAuthUserWithEmailAndPassword,
Expand All @@ -18,6 +19,7 @@ const INITIAL_FORM_FIELDS = {

export default function SignUpForm() {
const [formFields, setFormFields] = useState(INITIAL_FORM_FIELDS);
const { setCurrentUser } = useContext(UserContext);

function handleChange(event: ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target;
Expand All @@ -36,11 +38,13 @@ export default function SignUpForm() {
formFields.email,
formFields.password,
);

await createUserDocumentFromAuth(userCredentials.user, {
displayName: formFields.displayName,
});

setFormFields(INITIAL_FORM_FIELDS);
setCurrentUser(userCredentials.user);
} catch (error) {
if ((error as AuthError).code === "auth/email-already-in-use") {
return alert("Cannot create user, email already in use!");
Expand Down
28 changes: 28 additions & 0 deletions src/contexts/userContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render } from "@testing-library/react";
import { useContext, useEffect } from "react";

import { User } from "firebase/auth";
import UserContextProvider, { UserContext } from "./userContext";

function setup() {
function Consumer() {
const { currentUser, setCurrentUser } = useContext(UserContext);

useEffect(() => {
setCurrentUser({ displayName: "Alice" } as User);
}, [setCurrentUser]);

return <div>{currentUser?.displayName}</div>;
}

render(
<UserContextProvider>
<Consumer />
</UserContextProvider>,
);
}

test("should provide the correct context values", () => {
setup();
expect(document.body.textContent).toBe("Alice");
});
24 changes: 24 additions & 0 deletions src/contexts/userContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { User } from "firebase/auth";
import { createContext, Dispatch, SetStateAction, useState } from "react";

interface IUserContext {
currentUser: User | null;
setCurrentUser: Dispatch<SetStateAction<User | null>>;
}

// eslint-disable-next-line react-refresh/only-export-components
export const UserContext = createContext<IUserContext>({
currentUser: null,
setCurrentUser: () => {},
});

export default function UserContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const value = { currentUser, setCurrentUser };

return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
6 changes: 5 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import UserContextProvider from "@/contexts/userContext.tsx";

import App from "./App.tsx";
import "./index.css";

const rootEl = document.getElementById("root");

createRoot(rootEl!).render(
<StrictMode>
<App />
<UserContextProvider>
<App />
</UserContextProvider>
</StrictMode>,
);
Loading

0 comments on commit 78bfdc6

Please sign in to comment.