Compare commits

...

15 Commits

Author SHA1 Message Date
Ilia Mashkov
f71ed9285b test(auth): add token store reset after each test 2026-04-02 12:48:20 +03:00
Ilia Mashkov
6516172a6b test(auth): add token store reset after each test 2026-04-02 12:47:48 +03:00
Ilia Mashkov
f98ccf468c test(auth): wrap component rendering in act() 2026-04-02 12:47:22 +03:00
Ilia Mashkov
4d95159c4f feat(RegisterForm): add custom input and button components support with component as prop pattern 2026-04-02 12:46:45 +03:00
Ilia Mashkov
e7ac79049d chore: rewrite LoginForm to use defaults from separate files 2026-04-02 12:46:15 +03:00
Ilia Mashkov
85763e568f feat(auth): add separate default input component 2026-04-02 12:45:32 +03:00
Ilia Mashkov
98e3133d88 feat(auth): add separate default button component 2026-04-02 12:45:25 +03:00
Ilia Mashkov
8d283064a0 feat(LoginForm): use component as prop pattern to add customizable input and button components 2026-03-31 12:54:43 +03:00
Ilia Mashkov
a6f4b993dd feat(auth): set the token after login/register/logout 2026-03-31 12:53:41 +03:00
Ilia Mashkov
3d7eb850ec fix(auth): remove unnecessary token header setup 2026-03-31 12:52:43 +03:00
Ilia Mashkov
69d026afce feat(auth): add selectors for email and password 2026-03-31 12:51:41 +03:00
Ilia Mashkov
87511398fb test: change mocks to comply with the new validation 2026-03-25 10:43:47 +03:00
Ilia Mashkov
542de6c540 chore: delete empty lines between test cases 2026-03-25 10:43:09 +03:00
Ilia Mashkov
6d81eaba4b feat(auth): add validatePassword function with requirements for min and max characters amount, presence of at least one uppercase letter, one lowercase letter, one digit and one special symbol; cover it with tests 2026-03-25 10:34:54 +03:00
Ilia Mashkov
5b49398665 feat(auth): add an RFC complient validation function for an email address; cover it with tests 2026-03-25 10:18:55 +03:00
19 changed files with 434 additions and 84 deletions

View File

@@ -4,7 +4,7 @@ import { MOCK_TOKEN } from "shared/api";
export const MOCK_EMAIL = "test@test.com"; export const MOCK_EMAIL = "test@test.com";
export const MOCK_NEW_EMAIL = "new@test.com"; export const MOCK_NEW_EMAIL = "new@test.com";
export const MOCK_PASSWORD = "password"; export const MOCK_PASSWORD = "100%GoodPassword";
export const MOCK_EXISTING_USER: User = { export const MOCK_EXISTING_USER: User = {
id: "1", id: "1",

View File

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

View File

@@ -1,2 +1,4 @@
export { useAuth } from "./hooks/useAuth/useAuth"; export { useAuth } from "./hooks/useAuth/useAuth";
export { AuthGuard } from "./hocs/AuthGuard/AuthGuard"; export { AuthGuard } from "./hocs/AuthGuard/AuthGuard";
export * from "./validators";

View File

@@ -0,0 +1,2 @@
export { validateEmail } from "./validateEmail/validateEmail";
export { validatePassword } from "./validatePassword/validatePassword";

View File

@@ -0,0 +1,87 @@
import {
MAX_DOMAIN_EXTENSION_LENGTH,
MAX_DOMAIN_LABEL_LENGTH,
MAX_DOMAIN_LENGTH,
MAX_LOCAL_PART_LENGTH,
validateEmail,
} from "./validateEmail";
describe("validateEmail", () => {
describe("Absence of some parts of an email", () => {
it("should return false if there's no separator (@ symbol)", () => {
expect(validateEmail("testexample.com")).toBe(false);
});
it("should return false for an email without a domain extension", () => {
expect(validateEmail("test@example")).toBe(false);
});
it("should return false for an email without a domain", () => {
expect(validateEmail("test@")).toBe(false);
});
it("should return false for an email with no local part", () => {
expect(validateEmail("@example.com")).toBe(false);
});
});
describe("Excessive amount of some parts of an email", () => {
it("should return false for an email with multiple separators", () => {
expect(validateEmail("test@example@com")).toBe(false);
});
});
describe("Local part", () => {
it("should return false for consecutive dots in local part (unquoted)", () => {
expect(validateEmail("john..doe@example.com")).toBe(false);
});
it("should return true for consecutive dots inside a quoted string", () => {
expect(validateEmail('"john..doe"@example.com')).toBe(true);
});
it("should return false for leading/trailing dots in local part", () => {
expect(validateEmail(".john@example.com")).toBe(false);
expect(validateEmail("john.@example.com")).toBe(false);
});
it("should return true for special characters in unquoted local part", () => {
expect(validateEmail("!#$%&'*+-/=?^_`{|}~@example.com")).toBe(true);
});
it("should return true for escaped characters and spaces inside quotes", () => {
expect(validateEmail('"John Doe"@example.com')).toBe(true);
expect(validateEmail('"John\\"Doe"@example.com')).toBe(true);
});
it("should return false if the local part is too long", () => {
const longLocalPart = "a".repeat(MAX_LOCAL_PART_LENGTH + 1);
expect(validateEmail(longLocalPart + "@example.com")).toBe(false);
});
});
describe("Domain", () => {
it("should return true for a domain starting with a digit", () => {
expect(validateEmail("test@123example.com")).toBe(true);
});
it("should return false for a domain label ending with a hyphen", () => {
expect(validateEmail("test@example-.com")).toBe(false);
});
it("should return false for a domain label starting with a hyphen", () => {
expect(validateEmail("test@-example.com")).toBe(false);
});
it("should return false if a middle label exceeds maximum allowed characters", () => {
const longLabel = "a".repeat(MAX_DOMAIN_LABEL_LENGTH + 1);
expect(validateEmail(`test@${longLabel}.com`)).toBe(false);
});
it("should return true for complex subdomains", () => {
expect(validateEmail("test@sub.sub-label.example.com")).toBe(true);
});
it("should return false if the domain is too long", () => {
const longDomain = "a".repeat(MAX_DOMAIN_LENGTH + 1);
expect(validateEmail(`test@${longDomain}.com`)).toBe(false);
});
it("should return false if the domain extension is too long", () => {
const longExtension = "a".repeat(MAX_DOMAIN_EXTENSION_LENGTH + 1);
expect(validateEmail(`test@example.${longExtension}`)).toBe(false);
});
});
describe("Valid emails", () => {
it("should return true for a valid email", () => {
expect(validateEmail("test@example.com")).toBe(true);
});
});
});

View File

@@ -0,0 +1,34 @@
export const MAX_LOCAL_PART_LENGTH = 63;
export const MAX_DOMAIN_LENGTH = 255;
export const MAX_DOMAIN_EXTENSION_LENGTH = 63;
export const MAX_DOMAIN_LABEL_LENGTH = 63;
const MAX_DOMAIN_LABEL_LENGTH_WITHOUT_START_AND_END_CHARS = Math.max(
MAX_DOMAIN_LABEL_LENGTH - 2,
0,
);
// RFC 5322 compliant email regex
export const EMAIL_REGEX = new RegExp(
"^" +
`(?=[^@]{1,${MAX_LOCAL_PART_LENGTH}}@)` +
"(?:(?:" +
'"(?:[^"\\\\]|\\\\.)+"' + // Quoted string support
")|(?:" +
"[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" +
"(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" + // Dot restrictions
"))" +
"@" +
`(?=.{1,${MAX_DOMAIN_LENGTH}}$)` +
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,${MAX_DOMAIN_LABEL_LENGTH_WITHOUT_START_AND_END_CHARS}}[a-zA-Z0-9])?\\.)+` +
`[a-zA-Z]{2,${MAX_DOMAIN_EXTENSION_LENGTH}}` +
"$",
);
/**
* Validates an email address using a regular expression.
* @param email The email address to validate.
* @returns `true` if the email is valid, `false` otherwise.
*/
export function validateEmail(email: string): boolean {
return EMAIL_REGEX.test(email);
}

View File

@@ -0,0 +1,66 @@
import {
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
validatePassword,
} from "./validatePassword";
describe("validatePassword", () => {
describe("Absence of necessary characters", () => {
it("should return false when the password does not contain a lowercase letter", () => {
expect(validatePassword("PASSWORD123!")).toBe(false);
});
it("should return false when the password does not contain an uppercase letter", () => {
expect(validatePassword("password123!")).toBe(false);
});
it("should return false when the password does not contain a digit", () => {
expect(validatePassword("Password!")).toBe(false);
});
it("should return false when the password does not contain a special character", () => {
expect(validatePassword("Password123")).toBe(false);
});
});
describe("Length requirement", () => {
const validChars = "aA1!";
it("should return false when the password is less than min length", () => {
const shortPassword =
validChars +
"a".repeat(Math.max(0, MIN_PASSWORD_LENGTH - validChars.length - 1));
expect(validatePassword(shortPassword)).toBe(false);
});
it("should return true when the password is exactly min length", () => {
const exactMinPassword =
validChars +
"a".repeat(Math.max(0, MIN_PASSWORD_LENGTH - validChars.length));
expect(validatePassword(exactMinPassword)).toBe(true);
});
it("should return false when the password is greater than max length", () => {
const longPassword =
validChars +
"a".repeat(Math.max(0, MAX_PASSWORD_LENGTH - validChars.length + 1));
expect(validatePassword(longPassword)).toBe(false);
});
it("should return true when the password is exactly max length", () => {
const exactMaxPassword =
validChars +
"a".repeat(Math.max(0, MAX_PASSWORD_LENGTH - validChars.length));
expect(validatePassword(exactMaxPassword)).toBe(true);
});
});
describe("Valid password", () => {
it("should return true when the password is valid", () => {
const validChars = "aA1!";
const validPassword =
validChars +
"a".repeat(Math.max(0, MIN_PASSWORD_LENGTH - validChars.length));
expect(validatePassword(validPassword)).toBe(true);
});
});
});

View File

@@ -0,0 +1,19 @@
export const MIN_PASSWORD_LENGTH = 11;
export const MAX_PASSWORD_LENGTH = 63;
/**
* The password regex used to validate passwords.
* Uses a case-sensitive regex with at least one lowercase letter, one uppercase letter, one digit, and one special character.
*/
export const PASSWORD_REGEX = new RegExp(
`^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_LENGTH}}$`,
);
/**
* Validates a password against the password regex.
* @param password The password to validate.
* @returns `true` if the password is valid, `false` otherwise.
*/
export function validatePassword(password: string): boolean {
return PASSWORD_REGEX.test(password);
}

View File

@@ -1,3 +1,5 @@
export * from "./selectFormValid/selectFormValid"; export * from "./selectFormValid/selectFormValid";
export * from "./selectAuthData/selectAuthData"; export * from "./selectAuthData/selectAuthData";
export * from "./selectStatusIsLoading/selectStatusIsLoading"; 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_NEW_EMAIL,
MOCK_PASSWORD, MOCK_PASSWORD,
} from "../../../api/calls/mocks"; } from "../../../api/calls/mocks";
import { useTokenStore } from "shared/api";
const server = setupServer( const server = setupServer(
apiCalls.loginMock, apiCalls.loginMock,
@@ -21,6 +22,7 @@ describe("authStore", () => {
beforeAll(() => server.listen({ onUnhandledRequest: "error" })); beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { afterEach(() => {
useAuthStore.getState().reset(); useAuthStore.getState().reset();
useTokenStore.getState().reset();
server.resetHandlers(); server.resetHandlers();
}); });
afterAll(() => server.close()); afterAll(() => server.close());
@@ -88,7 +90,7 @@ describe("authStore", () => {
it("should set error and update status if login fails", async () => { it("should set error and update status if login fails", async () => {
useAuthStore.getState().setEmail("wrong@test.com"); useAuthStore.getState().setEmail("wrong@test.com");
useAuthStore.getState().setPassword("wrongPassword"); useAuthStore.getState().setPassword("100%WrongPassword");
await useAuthStore.getState().login(); await useAuthStore.getState().login();

View File

@@ -2,8 +2,9 @@ import { create } from "zustand";
import type { AuthStore, AuthStoreState } from "../../types/store"; import type { AuthStore, AuthStoreState } from "../../types/store";
import { login, logout, register } from "../../../api"; import { login, logout, register } from "../../../api";
import { callApi } from "shared/utils"; 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 { selectAuthData, selectFormValid } from "../../selectors";
import { validateEmail, validatePassword } from "../../../lib";
export const defaultStoreState: Readonly<AuthStoreState> = { export const defaultStoreState: Readonly<AuthStoreState> = {
formData: { formData: {
@@ -15,14 +16,6 @@ export const defaultStoreState: Readonly<AuthStoreState> = {
error: null, error: null,
}; };
function validateEmail(email: string): boolean {
return Boolean(email);
}
function validatePassword(password: string): boolean {
return Boolean(password);
}
export const useAuthStore = create<AuthStore>()((set, get) => ({ export const useAuthStore = create<AuthStore>()((set, get) => ({
...defaultStoreState, ...defaultStoreState,
@@ -75,6 +68,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
user: responseData?.user, user: responseData?.user,
error: null, error: null,
}); });
useTokenStore.setState({ accessToken: responseData?.accessToken });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
set({ set({
@@ -115,6 +109,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
user: responseData?.user, user: responseData?.user,
error: null, error: null,
}); });
useTokenStore.setState({ accessToken: responseData?.accessToken });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
set({ set({
@@ -145,7 +140,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
error: null, error: null,
}); });
// useTokenStore.setState({ accessToken: null }); useTokenStore.setState({ accessToken: null });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
set({ error: new Error(UNEXPECTED_ERROR_MESSAGE) }); 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 { 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 userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm"; import { LoginForm } from "./LoginForm";
import { MOCK_EMAIL, MOCK_PASSWORD } from "../../api"; import { MOCK_EMAIL, MOCK_PASSWORD } from "../../api";
import { useTokenStore } from "shared/api";
describe("LoginForm", () => { describe("LoginForm", () => {
afterEach(() => { afterEach(() => {
useAuthStore.getState().reset(); useAuthStore.getState().reset();
useTokenStore.getState().reset();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("should render form", () => { it("should render form", () => {
render(<LoginForm />); act(() => render(<LoginForm />));
const form = screen.getByRole("form"); const form = screen.getByRole("form");
expect(form).toBeInTheDocument(); expect(form).toBeInTheDocument();
}); });
it("disables submit button when form is invalid", () => { it("disables submit button when form is invalid", () => {
render(<LoginForm />); act(() => render(<LoginForm />));
const loginButton = screen.getByRole("button", { name: /login/i }); const loginButton = screen.getByRole("button", { name: /login/i });
expect(loginButton).toBeDisabled(); expect(loginButton).toBeDisabled();
@@ -27,7 +29,7 @@ describe("LoginForm", () => {
it("disables submit button when auth store status equals loading", async () => { it("disables submit button when auth store status equals loading", async () => {
useAuthStore.setState({ status: "loading" }); useAuthStore.setState({ status: "loading" });
render(<LoginForm />); act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/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 () => { it("enables submit button when form is valid and auth store status isn't equal to loading", async () => {
useAuthStore.setState({ status: "idle" }); useAuthStore.setState({ status: "idle" });
render(<LoginForm />); act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
@@ -53,7 +55,7 @@ describe("LoginForm", () => {
}); });
it("updates email value in auth store when user types", async () => { it("updates email value in auth store when user types", async () => {
render(<LoginForm />); act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
await userEvent.type(emailInput, MOCK_EMAIL); await userEvent.type(emailInput, MOCK_EMAIL);
@@ -62,7 +64,7 @@ describe("LoginForm", () => {
}); });
it("updates password value in auth store when user types", async () => { it("updates password value in auth store when user types", async () => {
render(<LoginForm />); act(() => render(<LoginForm />));
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
await userEvent.type(passwordInput, MOCK_PASSWORD); await userEvent.type(passwordInput, MOCK_PASSWORD);
@@ -74,7 +76,7 @@ describe("LoginForm", () => {
const loginSpy = vi.spyOn(useAuthStore.getState(), "login"); const loginSpy = vi.spyOn(useAuthStore.getState(), "login");
useAuthStore.setState({ status: "idle" }); useAuthStore.setState({ status: "idle" });
render(<LoginForm />); act(() => render(<LoginForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);

View File

@@ -1,16 +1,37 @@
import { import {
selectEmail,
selectFormValid, selectFormValid,
selectPassword,
selectStatusIsLoading, selectStatusIsLoading,
useAuthStore, useAuthStore,
} from "../../model"; } 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 * Login form component
*/ */
export function LoginForm() { export function LoginForm({ InputComponent, ButtonComponent }: Props) {
const { formData, setEmail, setPassword, login } = useAuthStore(); const { setEmail, setPassword, login } = useAuthStore();
const email = useAuthStore(selectEmail);
const password = useAuthStore(selectPassword);
const formValid = useAuthStore(selectFormValid); const formValid = useAuthStore(selectFormValid);
const isLoading = useAuthStore(selectStatusIsLoading); const isLoading = useAuthStore(selectStatusIsLoading);
@@ -31,21 +52,47 @@ export function LoginForm() {
return ( return (
<form aria-label="Login form" onSubmit={handleSubmit}> <form aria-label="Login form" onSubmit={handleSubmit}>
<input {/* EMAIL */}
type="email" {InputComponent ? (
aria-label="Email" cloneElement(InputComponent, {
value={formData?.email?.value ?? ""} value: email,
onChange={handleEmailChange} onChange: handleEmailChange,
/> "aria-label": "Email",
<input })
type="password" ) : (
aria-label="Password" <DefaultInputComponent
value={formData?.password?.value ?? ""} type="email"
onChange={handlePasswordChange} value={email}
/> onChange={handleEmailChange}
<button type="submit" disabled={disabled}> aria-label="Email"
Login />
</button> )}
{/* 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> </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 { RegisterForm } from "./RegisterForm";
import { selectAuthData, useAuthStore } from "../../model"; import { selectAuthData, useAuthStore } from "../../model";
import { MOCK_NEW_EMAIL, MOCK_PASSWORD } from "../../api"; import { MOCK_NEW_EMAIL, MOCK_PASSWORD } from "../../api";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { useTokenStore } from "shared/api";
describe("RegisterForm", () => { describe("RegisterForm", () => {
afterEach(() => { afterEach(() => {
useAuthStore.getState().reset(); useAuthStore.getState().reset();
useTokenStore.getState().reset();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("should render form", () => { it("should render form", () => {
render(<RegisterForm />); act(() => render(<RegisterForm />));
const form = screen.getByRole("form"); const form = screen.getByRole("form");
expect(form).toBeInTheDocument(); expect(form).toBeInTheDocument();
}); });
it("should disable button when form is invalid", async () => { it("should disable button when form is invalid", async () => {
render(<RegisterForm />); act(() => render(<RegisterForm />));
const registerButton = screen.getByRole("button", { name: /register/i }); 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 () => { it("should disable button when auth store status equals loading", async () => {
useAuthStore.setState({ status: "loading" }); useAuthStore.setState({ status: "loading" });
render(<RegisterForm />); act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/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 () => { 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 emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/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 () => { it("should enable button when password and confirm password match and auth store status isn't equal to loading", async () => {
useAuthStore.setState({ status: "idle" }); useAuthStore.setState({ status: "idle" });
render(<RegisterForm />); act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/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 () => { 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 }); const emailInput = screen.getByRole("textbox", { name: /email/i });
await userEvent.type(emailInput, MOCK_NEW_EMAIL); 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 () => { it("should change password value in auth store when user types", async () => {
render(<RegisterForm />); act(() => render(<RegisterForm />));
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
await userEvent.type(passwordInput, MOCK_PASSWORD); await userEvent.type(passwordInput, MOCK_PASSWORD);
@@ -98,7 +100,7 @@ describe("RegisterForm", () => {
const registerSpy = vi.spyOn(useAuthStore.getState(), "register"); const registerSpy = vi.spyOn(useAuthStore.getState(), "register");
useAuthStore.setState({ status: "idle" }); useAuthStore.setState({ status: "idle" });
render(<RegisterForm />); act(() => render(<RegisterForm />));
const emailInput = screen.getByRole("textbox", { name: /email/i }); const emailInput = screen.getByRole("textbox", { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);

View File

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