diff --git a/frontend/src/components/auth/CredentialsForm.test.tsx b/frontend/src/components/auth/CredentialsForm.test.tsx new file mode 100644 index 0000000..15111d6 --- /dev/null +++ b/frontend/src/components/auth/CredentialsForm.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CredentialsForm, type CredentialsFormMode } from './CredentialsForm' + +const labels = { + loginTab: 'Log in', + registerTab: 'Create account', + nameLabel: 'Name', + namePlaceholder: 'Your name', + emailLabel: 'Email', + emailPlaceholder: 'Email address', + passwordLabel: 'Password', + passwordPlaceholder: 'Password', + submitLabel: 'Submit', +} + +function renderForm(mode: CredentialsFormMode, overrides: { error?: string; disabled?: boolean } = {}) { + const onModeChange = vi.fn() + const onSubmit = vi.fn() + render( + , + ) + return { onModeChange, onSubmit } +} + +describe('CredentialsForm', () => { + it('renders the login mode without a name field', () => { + renderForm('login') + expect(screen.getByTestId('cf-tab-login')).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByTestId('cf-tab-register')).toHaveAttribute('aria-pressed', 'false') + expect(screen.queryByPlaceholderText('Your name')).not.toBeInTheDocument() + expect(screen.getByTestId('cf-email')).toBeInTheDocument() + expect(screen.getByTestId('cf-password')).toBeInTheDocument() + }) + + it('renders the register mode with a name field', () => { + renderForm('register') + expect(screen.getByTestId('cf-tab-register')).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByTestId('cf-tab-login')).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByPlaceholderText('Your name')).toBeInTheDocument() + }) + + it('calls onModeChange when a tab is clicked', () => { + const { onModeChange } = renderForm('login') + fireEvent.click(screen.getByTestId('cf-tab-register')) + expect(onModeChange).toHaveBeenCalledWith('register') + + fireEvent.click(screen.getByTestId('cf-tab-login')) + expect(onModeChange).toHaveBeenCalledWith('login') + }) + + it('shows the error alert when an error is given', () => { + renderForm('login', { error: 'Something went wrong' }) + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong') + }) + + it('renders no alert when there is no error', () => { + renderForm('login') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('submits the entered name, email and password for register mode', () => { + const { onSubmit } = renderForm('register') + + fireEvent.change(screen.getByPlaceholderText('Your name'), { target: { value: 'Alex' } }) + fireEvent.change(screen.getByTestId('cf-email'), { target: { value: 'a@b.com' } }) + fireEvent.change(screen.getByTestId('cf-password'), { target: { value: 'secret' } }) + fireEvent.click(screen.getByTestId('cf-submit')) + + expect(onSubmit).toHaveBeenCalledWith({ displayName: 'Alex', email: 'a@b.com', password: 'secret' }) + }) + + it('submits an empty displayName for login mode, where the field is hidden', () => { + const { onSubmit } = renderForm('login') + + fireEvent.change(screen.getByTestId('cf-email'), { target: { value: 'a@b.com' } }) + fireEvent.change(screen.getByTestId('cf-password'), { target: { value: 'secret' } }) + fireEvent.click(screen.getByTestId('cf-submit')) + + expect(onSubmit).toHaveBeenCalledWith({ displayName: '', email: 'a@b.com', password: 'secret' }) + }) + + it('disables the submit button when disabled is true', () => { + renderForm('login', { disabled: true }) + expect(screen.getByTestId('cf-submit')).toBeDisabled() + }) + + it('does not disable the submit button when disabled is not given', () => { + renderForm('login') + expect(screen.getByTestId('cf-submit')).not.toBeDisabled() + }) +}) diff --git a/frontend/src/components/auth/CredentialsForm.tsx b/frontend/src/components/auth/CredentialsForm.tsx new file mode 100644 index 0000000..8955024 --- /dev/null +++ b/frontend/src/components/auth/CredentialsForm.tsx @@ -0,0 +1,135 @@ +import { useState, type ReactNode } from 'react' +import { FormField } from '@/components/common/FormField' +import { Alert } from '@/components/common/Alert' + +export type CredentialsFormMode = 'login' | 'register' + +export interface CredentialsFormValues { + displayName: string + email: string + password: string +} + +/** All copy the form needs, so the two callers (`Auth`, `Join`) can supply + * their own translated strings without this component knowing their i18n + * keys. */ +export interface CredentialsFormLabels { + loginTab: ReactNode + registerTab: ReactNode + nameLabel: ReactNode + namePlaceholder: string + emailLabel: ReactNode + emailPlaceholder: string + passwordLabel: ReactNode + passwordPlaceholder: string + submitLabel: ReactNode +} + +export interface CredentialsFormProps { + mode: CredentialsFormMode + onModeChange: (mode: CredentialsFormMode) => void + onSubmit: (values: CredentialsFormValues) => void + error?: ReactNode + disabled?: boolean | undefined + /** Prefixes every id/data-testid, e.g. `auth` → `auth-email`. */ + idPrefix: string + /** Prefixes the BEM classes for the tabs/form, e.g. `auth-page` → + * `auth-page__tabs`. */ + classPrefix: string + labels: CredentialsFormLabels +} + +/** + * The login/register tab pair + credentials form shared by `Auth` (the + * app's front door) and `Join` (the partner-invite landing page) — until + * now hand-duplicated between them. Owns the field values internally; + * callers only see the submitted `{ displayName, email, password }` tuple + * and the mode toggle, and stay responsible for the error banner's content, + * the submit button's disabled state and label, and what happens on submit. + */ +export function CredentialsForm({ + mode, + onModeChange, + onSubmit, + error, + disabled, + idPrefix, + classPrefix, + labels, +}: CredentialsFormProps): ReactNode { + const [displayName, setDisplayName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const isRegister = mode === 'register' + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ displayName, email, password }) + } + + return ( + <> +
+ + +
+
+ {isRegister && ( + + setDisplayName(e.target.value)} + /> + + )} + + setEmail(e.target.value)} + /> + + + setPassword(e.target.value)} + /> + + {error && {error}} + +
+ + ) +} diff --git a/frontend/src/components/balance/BalanceFlow.tsx b/frontend/src/components/balance/BalanceFlow.tsx index d587db2..b678621 100644 --- a/frontend/src/components/balance/BalanceFlow.tsx +++ b/frontend/src/components/balance/BalanceFlow.tsx @@ -14,6 +14,7 @@ import type { PersonalData } from '@/types' import { QRCodeModal } from '@/components/modals/QRCodeModal' import { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm' import { LoadingSpinner } from '@/components/common/LoadingSpinner' +import { Alert } from '@/components/common/Alert' import './balance-flow.css' export interface BalanceFlowProps { @@ -325,11 +326,7 @@ export function BalanceFlow({ disabled={isAddingDeducedPartner} /> - {deducedPartnerError && ( -
- {deducedPartnerError} -
- )} + {deducedPartnerError && {deducedPartnerError}}
- {(error || localError) && ( -
- {error || localError} -
- )} + {(error || localError) && {error || localError}} {!wish && (

@@ -388,12 +386,8 @@ export function PeriodPopover({ )}

-
- + setForm((prev) => ({ ...prev, name: e.target.value }))} @@ -403,14 +397,10 @@ export function PeriodPopover({ data-testid="period-name-input" aria-label={t('calendar.periodNameAria')} /> -
+ -
- + setForm((prev) => ({ ...prev, startDate: e.target.value }))} @@ -418,14 +408,10 @@ export function PeriodPopover({ disabled={isSaving} data-testid="period-start-input" /> -
+ -
- + setForm((prev) => ({ ...prev, endDate: e.target.value }))} @@ -433,14 +419,10 @@ export function PeriodPopover({ disabled={isSaving} data-testid="period-end-input" /> -
+ -
- + -
+
diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx index 9111744..8fdd9aa 100644 --- a/frontend/src/pages/Onboarding.tsx +++ b/frontend/src/pages/Onboarding.tsx @@ -7,6 +7,8 @@ import { useTranslation, type TranslationKey, type TranslationParams } from '@/i import { Layout } from '@/components/layout/Layout' import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' import { BalanceFlow } from '@/components/balance/BalanceFlow' +import { FormField } from '@/components/common/FormField' +import { Alert } from '@/components/common/Alert' import './onboarding.css' type Step = 'child' | 'family' | 'balances' @@ -171,12 +173,13 @@ export function Onboarding(): ReactNode { `.field__error`, no localized message), so this form owns validation itself via `handleCreateChild`'s field checks. */}
void handleCreateChild(e)} noValidate> -
- + - {childNameError && ( -

- {childNameError} -

- )} -
+
- {childError && ( -
- {childError} -
- )} + {childError && {childError}}
- {familyError && ( -
- {familyError} -
- )} + {familyError && {familyError}}
{!isSelf && (isConfirmingRemove ? ( - - {t('settings.removeConfirmPrompt', { name: member.name })} - - - + handleConfirmRemove(member.uuid)} + onCancel={handleCancelRemove} + isBusy={isSavingFamily} + confirmTestId={`remove-confirm-${member.uuid}`} + cancelTestId={`remove-cancel-${member.uuid}`} + confirmFirst + small + /> ) : (