Compare commits

..

11 Commits

12 changed files with 221 additions and 74 deletions

View File

@@ -2,17 +2,7 @@ import { api as baseApi, useTokenStore } from "shared/api";
export const authHttpClient = baseApi.extend({
hooks: {
beforeRequest: [
(request) => {
const token = useTokenStore.getState().accessToken;
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
return request;
},
],
beforeRequest: [],
afterResponse: [
async (request, options, response) => {
if (response.status !== 401) {

View File

@@ -1,3 +1,5 @@
export * from "./selectFormValid/selectFormValid";
export * from "./selectAuthData/selectAuthData";
export * from "./selectStatusIsLoading/selectStatusIsLoading";
export * from "./selectEmail/selectEmail";
export * from "./selectPassword/selectPassword";

View File

@@ -0,0 +1,4 @@
import type { AuthStore } from "../../types/store";
export const selectEmail = (state: AuthStore) =>
state.formData?.email?.value ?? "";

View File

@@ -0,0 +1,4 @@
import type { AuthStore } from "../../types/store";
export const selectPassword = (state: AuthStore) =>
state.formData?.password?.value ?? "";

View File

@@ -6,6 +6,7 @@ import {
MOCK_NEW_EMAIL,
MOCK_PASSWORD,
} from "../../../api/calls/mocks";
import { useTokenStore } from "shared/api";
const server = setupServer(
apiCalls.loginMock,
@@ -21,6 +22,7 @@ describe("authStore", () => {
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
useAuthStore.getState().reset();
useTokenStore.getState().reset();
server.resetHandlers();
});
afterAll(() => server.close());

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import type { AuthStore, AuthStoreState } from "../../types/store";
import { login, logout, register } from "../../../api";
import { callApi } from "shared/utils";
import { UNEXPECTED_ERROR_MESSAGE } from "shared/api";
import { UNEXPECTED_ERROR_MESSAGE, useTokenStore } from "shared/api";
import { selectAuthData, selectFormValid } from "../../selectors";
import { validateEmail, validatePassword } from "../../../lib";
@@ -68,6 +68,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
user: responseData?.user,
error: null,
});
useTokenStore.setState({ accessToken: responseData?.accessToken });
} catch (err) {
console.error(err);
set({
@@ -108,6 +109,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
user: responseData?.user,
error: null,
});
useTokenStore.setState({ accessToken: responseData?.accessToken });
} catch (err) {
console.error(err);
set({
@@ -138,7 +140,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
error: null,
});
// useTokenStore.setState({ accessToken: null });
useTokenStore.setState({ accessToken: null });
} catch (err) {
console.error(err);
set({ error: new Error(UNEXPECTED_ERROR_MESSAGE) });

View File

@@ -0,0 +1,14 @@
import type { ButtonHTMLAttributes } from "react";
export type ButtonAttributes = ButtonHTMLAttributes<HTMLButtonElement>;
export function DefaultButtonComponent({
disabled,
children,
}: ButtonAttributes) {
return (
<button type="submit" disabled={disabled}>
{children}
</button>
);
}

View File

@@ -0,0 +1,19 @@
import type { InputHTMLAttributes } from "react";
export type InputAttributes = InputHTMLAttributes<HTMLInputElement>;
export function DefaultInputComponent({
type,
value,
onChange,
["aria-label"]: ariaLabel,
}: InputAttributes) {
return (
<input
type={type}
value={value}
onChange={onChange}
aria-label={ariaLabel}
/>
);
}

View File

@@ -1,24 +1,26 @@
import { selectAuthData, useAuthStore } from "../../model";
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
import { MOCK_EMAIL, MOCK_PASSWORD } from "../../api";
import { useTokenStore } from "shared/api";
describe("LoginForm", () => {
afterEach(() => {
useAuthStore.getState().reset();
useTokenStore.getState().reset();
vi.restoreAllMocks();
});
it("should render form", () => {
render(<LoginForm />);
act(() => render(<LoginForm />));
const form = screen.getByRole("form");
expect(form).toBeInTheDocument();
});
it("disables submit button when form is invalid", () => {
render(<LoginForm />);
act(() => render(<LoginForm />));
const loginButton = screen.getByRole("button", { name: /login/i });
expect(loginButton).toBeDisabled();
@@ -27,7 +29,7 @@ describe("LoginForm", () => {
it("disables submit button when auth store status equals loading", async () => {
useAuthStore.setState({ status: "loading" });
render(<LoginForm />);
act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
@@ -41,7 +43,7 @@ describe("LoginForm", () => {
it("enables submit button when form is valid and auth store status isn't equal to loading", async () => {
useAuthStore.setState({ status: "idle" });
render(<LoginForm />);
act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
@@ -53,7 +55,7 @@ describe("LoginForm", () => {
});
it("updates email value in auth store when user types", async () => {
render(<LoginForm />);
act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
await userEvent.type(emailInput, MOCK_EMAIL);
@@ -62,7 +64,7 @@ describe("LoginForm", () => {
});
it("updates password value in auth store when user types", async () => {
render(<LoginForm />);
act(() => render(<LoginForm />));
const passwordInput = screen.getByLabelText(/password/i);
await userEvent.type(passwordInput, MOCK_PASSWORD);
@@ -74,7 +76,7 @@ describe("LoginForm", () => {
const loginSpy = vi.spyOn(useAuthStore.getState(), "login");
useAuthStore.setState({ status: "idle" });
render(<LoginForm />);
act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);

View File

@@ -1,16 +1,37 @@
import {
selectEmail,
selectFormValid,
selectPassword,
selectStatusIsLoading,
useAuthStore,
} from "../../model";
import type { SubmitEvent, ChangeEvent } from "react";
import {
type SubmitEvent,
type ChangeEvent,
cloneElement,
type ReactElement,
} from "react";
import {
DefaultButtonComponent,
type ButtonAttributes,
} from "../DefaultButton/DefaultButton";
import {
DefaultInputComponent,
type InputAttributes,
} from "../DefaultInput/DefaultInput";
export interface Props {
InputComponent?: ReactElement<InputAttributes>;
ButtonComponent?: ReactElement<ButtonAttributes>;
}
/**
* Login form component
*/
export function LoginForm() {
const { formData, setEmail, setPassword, login } = useAuthStore();
export function LoginForm({ InputComponent, ButtonComponent }: Props) {
const { setEmail, setPassword, login } = useAuthStore();
const email = useAuthStore(selectEmail);
const password = useAuthStore(selectPassword);
const formValid = useAuthStore(selectFormValid);
const isLoading = useAuthStore(selectStatusIsLoading);
@@ -31,21 +52,47 @@ export function LoginForm() {
return (
<form aria-label="Login form" onSubmit={handleSubmit}>
<input
type="email"
aria-label="Email"
value={formData?.email?.value ?? ""}
onChange={handleEmailChange}
/>
<input
type="password"
aria-label="Password"
value={formData?.password?.value ?? ""}
onChange={handlePasswordChange}
/>
<button type="submit" disabled={disabled}>
Login
</button>
{/* EMAIL */}
{InputComponent ? (
cloneElement(InputComponent, {
value: email,
onChange: handleEmailChange,
"aria-label": "Email",
})
) : (
<DefaultInputComponent
type="email"
value={email}
onChange={handleEmailChange}
aria-label="Email"
/>
)}
{/* PASSWORD */}
{InputComponent ? (
cloneElement(InputComponent, {
value: password,
onChange: handlePasswordChange,
"aria-label": "Password",
})
) : (
<DefaultInputComponent
type="password"
value={password}
onChange={handlePasswordChange}
aria-label="Password"
/>
)}
{/* BUTTON */}
{ButtonComponent ? (
cloneElement(ButtonComponent, {
type: "submit",
disabled: disabled,
})
) : (
<DefaultButtonComponent disabled={disabled}>
Login
</DefaultButtonComponent>
)}
</form>
);
}

View File

@@ -1,24 +1,26 @@
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { RegisterForm } from "./RegisterForm";
import { selectAuthData, useAuthStore } from "../../model";
import { MOCK_NEW_EMAIL, MOCK_PASSWORD } from "../../api";
import userEvent from "@testing-library/user-event";
import { useTokenStore } from "shared/api";
describe("RegisterForm", () => {
afterEach(() => {
useAuthStore.getState().reset();
useTokenStore.getState().reset();
vi.restoreAllMocks();
});
it("should render form", () => {
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const form = screen.getByRole("form");
expect(form).toBeInTheDocument();
});
it("should disable button when form is invalid", async () => {
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const registerButton = screen.getByRole("button", { name: /register/i });
@@ -28,7 +30,7 @@ describe("RegisterForm", () => {
it("should disable button when auth store status equals loading", async () => {
useAuthStore.setState({ status: "loading" });
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
@@ -43,7 +45,7 @@ describe("RegisterForm", () => {
});
it("should disable button when password and confirm password do not match", async () => {
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
@@ -60,7 +62,7 @@ describe("RegisterForm", () => {
it("should enable button when password and confirm password match and auth store status isn't equal to loading", async () => {
useAuthStore.setState({ status: "idle" });
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
@@ -75,7 +77,7 @@ describe("RegisterForm", () => {
});
it("should change email value in auth store when user types", async () => {
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
await userEvent.type(emailInput, MOCK_NEW_EMAIL);
@@ -85,7 +87,7 @@ describe("RegisterForm", () => {
});
it("should change password value in auth store when user types", async () => {
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const passwordInput = screen.getByLabelText(/password/i);
await userEvent.type(passwordInput, MOCK_PASSWORD);
@@ -98,7 +100,7 @@ describe("RegisterForm", () => {
const registerSpy = vi.spyOn(useAuthStore.getState(), "register");
useAuthStore.setState({ status: "idle" });
render(<RegisterForm />);
act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);

View File

@@ -1,22 +1,45 @@
import { useState, type ChangeEvent, type SubmitEvent } from "react";
import {
cloneElement,
useState,
type ChangeEvent,
type ReactElement,
type SubmitEvent,
} from "react";
import {
selectEmail,
selectFormValid,
selectPassword,
selectStatusIsLoading,
useAuthStore,
} from "../../model";
import {
DefaultInputComponent,
type InputAttributes,
} from "../DefaultInput/DefaultInput";
import {
DefaultButtonComponent,
type ButtonAttributes,
} from "../DefaultButton/DefaultButton";
export interface Props {
InputComponent?: ReactElement<InputAttributes>;
ButtonComponent?: ReactElement<ButtonAttributes>;
}
/**
* Register form component
*/
export function RegisterForm() {
const { formData, setEmail, setPassword, register } = useAuthStore();
export function RegisterForm({ InputComponent, ButtonComponent }: Props) {
const { setEmail, setPassword, register } = useAuthStore();
const email = useAuthStore(selectEmail);
const password = useAuthStore(selectPassword);
const [confirmedPassword, setConfirmedPassword] = useState("");
const formValid = useAuthStore(selectFormValid);
const isLoading = useAuthStore(selectStatusIsLoading);
const passwordMatch = formData?.password?.value === confirmedPassword;
const passwordMatch = password === confirmedPassword;
const disabled = isLoading || !formValid || !passwordMatch;
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
@@ -38,27 +61,63 @@ export function RegisterForm() {
return (
<form aria-label="Register Form" onSubmit={handleSubmit}>
<input
type="email"
aria-label="Email"
value={formData?.email?.value ?? ""}
onChange={handleEmailChange}
/>
<input
type="password"
aria-label="Password"
value={formData?.password?.value ?? ""}
onChange={handlePasswordChange}
/>
<input
type="password"
aria-label="Confirm"
value={confirmedPassword}
onChange={handleConfirmChange}
/>
<button type="submit" aria-label="Register" disabled={disabled}>
Register
</button>
{/* EMAIL */}
{InputComponent ? (
cloneElement(InputComponent, {
value: email,
onChange: handleEmailChange,
"aria-label": "Email",
})
) : (
<DefaultInputComponent
type="email"
value={email}
onChange={handleEmailChange}
aria-label="Email"
/>
)}
{/* PASSWORD */}
{InputComponent ? (
cloneElement(InputComponent, {
value: password,
onChange: handlePasswordChange,
"aria-label": "Password",
})
) : (
<DefaultInputComponent
type="password"
value={password}
onChange={handlePasswordChange}
aria-label="Password"
/>
)}
{/* PASSWORD */}
{InputComponent ? (
cloneElement(InputComponent, {
value: confirmedPassword,
onChange: handleConfirmChange,
"aria-label": "Confirm",
})
) : (
<DefaultInputComponent
type="password"
value={confirmedPassword}
onChange={handleConfirmChange}
aria-label="Confirm"
/>
)}
{/* BUTTON */}
{ButtonComponent ? (
cloneElement(ButtonComponent, {
type: "submit",
disabled: disabled,
})
) : (
<DefaultButtonComponent disabled={disabled}>
Register
</DefaultButtonComponent>
)}
</form>
);
}