diff --git a/frontend/src/components/Albums/CreateAlbumDialog.tsx b/frontend/src/components/Albums/CreateAlbumDialog.tsx index ca045d9ad..0cb9a1b71 100644 --- a/frontend/src/components/Albums/CreateAlbumDialog.tsx +++ b/frontend/src/components/Albums/CreateAlbumDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Dialog, DialogContent, @@ -16,6 +16,7 @@ import { CreateAlbumDialogProps } from '@/types/Album'; import { usePictoMutation } from '@/hooks/useQueryExtension'; import { createAlbum } from '@/api/api-functions'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { Eye, EyeOff } from 'lucide-react'; export const CreateAlbumDialog: React.FC = ({ isOpen, @@ -27,11 +28,33 @@ export const CreateAlbumDialog: React.FC = ({ description: '', is_locked: false, password: '', + confirmPassword: '', }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [errors, setErrors] = useState>({}); + const resetForm = () => { + setFormData({ + name: '', + description: '', + is_locked: false, + password: '', + confirmPassword: '', + }); + setShowPassword(false); + setShowConfirmPassword(false); + setErrors({}); + }; + + useEffect(() => { + if (!isOpen) { + resetForm(); + } + }, [isOpen]); + const createAlbumMutation = usePictoMutation({ - mutationFn: createAlbum, + mutationFn: (data: Parameters[0]) => createAlbum(data), }); useMutationFeedback(createAlbumMutation, { @@ -55,8 +78,15 @@ export const CreateAlbumDialog: React.FC = ({ newErrors.name = 'Album name is required'; } - if (formData.is_locked && !formData.password.trim()) { - newErrors.password = 'Password is required for locked albums'; + if (formData.is_locked) { + if (!formData.password.trim()) { + newErrors.password = 'Password is required for locked albums'; + } + if (!formData.confirmPassword.trim()) { + newErrors.confirmPassword = 'Confirm password is required'; + } else if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } } setErrors(newErrors); @@ -83,18 +113,12 @@ export const CreateAlbumDialog: React.FC = ({ }; const handleClose = () => { - setFormData({ - name: '', - description: '', - is_locked: false, - password: '', - }); - setErrors({}); + resetForm(); onClose(); }; return ( - + !open && handleClose()}>
@@ -117,10 +141,18 @@ export const CreateAlbumDialog: React.FC = ({ onChange={(e) => setFormData({ ...formData, name: e.target.value }) } + aria-invalid={!!errors.name} + aria-describedby={errors.name ? 'name-error' : undefined} className={errors.name ? 'border-destructive' : ''} /> {errors.name && ( -

{errors.name}

+ )} @@ -153,32 +185,136 @@ export const CreateAlbumDialog: React.FC = ({ - setFormData({ ...formData, is_locked: checked }) - } + onCheckedChange={(checked) => { + setFormData((prev) => ({ + ...prev, + is_locked: checked, + ...(checked ? {} : { password: '', confirmPassword: '' }), + })); + if (!checked) { + setShowPassword(false); + setShowConfirmPassword(false); + setErrors((prev) => { + const updated = { ...prev }; + delete updated.password; + delete updated.confirmPassword; + return updated; + }); + } + }} /> {/* Password Field (shown only if locked) */} {formData.is_locked && ( -
- - - setFormData({ ...formData, password: e.target.value }) - } - className={errors.password ? 'border-destructive' : ''} - /> - {errors.password && ( -

{errors.password}

- )} -
+ <> +
+ +
+ + setFormData({ ...formData, password: e.target.value }) + } + aria-invalid={!!errors.password} + aria-describedby={ + errors.password ? 'password-error' : undefined + } + className={ + errors.password ? 'border-destructive pr-10' : 'pr-10' + } + /> + +
+ {errors.password && ( + + )} +
+ +
+ +
+ + setFormData({ + ...formData, + confirmPassword: e.target.value, + }) + } + aria-invalid={!!errors.confirmPassword} + aria-describedby={ + errors.confirmPassword + ? 'confirm-password-error' + : undefined + } + className={ + errors.confirmPassword + ? 'border-destructive pr-10' + : 'pr-10' + } + /> + +
+ {errors.confirmPassword && ( + + )} +
+ )} diff --git a/frontend/src/components/Albums/__tests__/CreateAlbumDialog.test.tsx b/frontend/src/components/Albums/__tests__/CreateAlbumDialog.test.tsx new file mode 100644 index 000000000..647205a6e --- /dev/null +++ b/frontend/src/components/Albums/__tests__/CreateAlbumDialog.test.tsx @@ -0,0 +1,350 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@/test-utils'; +import { createAlbum } from '@/api/api-functions'; +import { CreateAlbumDialog } from '../CreateAlbumDialog'; + +jest.mock('@/api/api-functions', () => ({ + createAlbum: jest.fn(), +})); + +const mockCreateAlbum = jest.mocked(createAlbum); + +const renderDialog = ( + isOpen = true, + onClose = jest.fn(), + onSuccess = jest.fn(), +) => + render( + , + ); + +describe('CreateAlbumDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateAlbum.mockResolvedValue({ + success: true, + message: 'Album created', + data: { + id: 'a1', + name: 'New Album', + description: null, + is_locked: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + image_count: 0, + }, + }); + }); + + it('renders the dialog when isOpen is true', () => { + renderDialog(true); + expect( + screen.getByRole('heading', { name: /create new album/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/album name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/description/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/lock album/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/^password/i)).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(/confirm password/i), + ).not.toBeInTheDocument(); + }); + + it('shows error if album name is empty on submission', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByRole('button', { name: /create album/i })); + + const nameInput = screen.getByLabelText(/album name \*/i); + expect(screen.getByText('Album name is required')).toBeInTheDocument(); + expect(nameInput).toHaveAttribute('aria-invalid', 'true'); + expect(nameInput).toHaveAttribute('aria-describedby', 'name-error'); + expect(mockCreateAlbum).not.toHaveBeenCalled(); + }); + + it('reveals password and confirm password inputs when lock album is toggled on', async () => { + const user = userEvent.setup(); + renderDialog(); + + expect(screen.queryByLabelText(/^password/i)).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText(/lock album/i)); + + expect(screen.getByLabelText(/^password \*/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/confirm password \*/i)).toBeInTheDocument(); + }); + + it('toggles password and confirm password visibility', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByLabelText(/lock album/i)); + + const passwordInput = screen.getByLabelText(/^password \*/i); + const confirmPasswordInput = screen.getByLabelText(/confirm password \*/i); + const togglePasswordBtn = screen.getByRole('button', { + name: /show password/i, + }); + const toggleConfirmBtn = screen.getByRole('button', { + name: /show confirm password/i, + }); + + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(confirmPasswordInput).toHaveAttribute('type', 'password'); + expect(togglePasswordBtn).toHaveAttribute('aria-controls', 'password'); + expect(toggleConfirmBtn).toHaveAttribute( + 'aria-controls', + 'confirm-password', + ); + + await user.click(togglePasswordBtn); + expect(passwordInput).toHaveAttribute('type', 'text'); + expect( + screen.getByRole('button', { name: /hide password/i }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /hide password/i })); + expect(passwordInput).toHaveAttribute('type', 'password'); + + await user.click(toggleConfirmBtn); + expect(confirmPasswordInput).toHaveAttribute('type', 'text'); + expect( + screen.getByRole('button', { name: /hide confirm password/i }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { name: /hide confirm password/i }), + ); + expect(confirmPasswordInput).toHaveAttribute('type', 'password'); + }); + + it('validates password requirement when lock album is enabled', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText(/album name/i), 'Family Trip'); + await user.click(screen.getByLabelText(/lock album/i)); + + await user.click(screen.getByRole('button', { name: /create album/i })); + + const passwordInput = screen.getByLabelText(/^password \*/i); + const confirmPasswordInput = screen.getByLabelText(/confirm password \*/i); + + expect( + screen.getByText('Password is required for locked albums'), + ).toBeInTheDocument(); + expect( + screen.getByText('Confirm password is required'), + ).toBeInTheDocument(); + expect(passwordInput).toHaveAttribute('aria-invalid', 'true'); + expect(passwordInput).toHaveAttribute('aria-describedby', 'password-error'); + expect(confirmPasswordInput).toHaveAttribute('aria-invalid', 'true'); + expect(confirmPasswordInput).toHaveAttribute( + 'aria-describedby', + 'confirm-password-error', + ); + expect(mockCreateAlbum).not.toHaveBeenCalled(); + }); + + it('validates that confirm password is required if password is entered', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText(/album name/i), 'Family Trip'); + await user.click(screen.getByLabelText(/lock album/i)); + await user.type(screen.getByLabelText(/^password \*/i), 'securepass123'); + + await user.click(screen.getByRole('button', { name: /create album/i })); + + expect( + screen.getByText('Confirm password is required'), + ).toBeInTheDocument(); + expect(mockCreateAlbum).not.toHaveBeenCalled(); + }); + + it('validates that passwords must match', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText(/album name/i), 'Family Trip'); + await user.click(screen.getByLabelText(/lock album/i)); + await user.type(screen.getByLabelText(/^password \*/i), 'securepass123'); + await user.type( + screen.getByLabelText(/confirm password \*/i), + 'differentpass', + ); + + await user.click(screen.getByRole('button', { name: /create album/i })); + + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + expect(mockCreateAlbum).not.toHaveBeenCalled(); + }); + + it('clears password fields, visibility, and validation errors when lock toggle is turned off', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText(/album name/i), 'Family Trip'); + await user.click(screen.getByLabelText(/lock album/i)); + await user.type(screen.getByLabelText(/^password \*/i), 'secret123'); + await user.type( + screen.getByLabelText(/confirm password \*/i), + 'mismatchedpass', + ); + + await user.click(screen.getByRole('button', { name: /show password/i })); + await user.click( + screen.getByRole('button', { name: /show confirm password/i }), + ); + expect(screen.getByLabelText(/^password \*/i)).toHaveAttribute( + 'type', + 'text', + ); + expect(screen.getByLabelText(/confirm password \*/i)).toHaveAttribute( + 'type', + 'text', + ); + + await user.click(screen.getByRole('button', { name: /create album/i })); + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + + await user.click(screen.getByLabelText(/lock album/i)); + expect(screen.queryByLabelText(/^password/i)).not.toBeInTheDocument(); + expect( + screen.queryByText('Passwords do not match'), + ).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText(/lock album/i)); + const passwordInput = screen.getByLabelText(/^password \*/i); + const confirmPasswordInput = screen.getByLabelText(/confirm password \*/i); + expect(passwordInput).toHaveValue(''); + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).not.toHaveAttribute('aria-invalid', 'true'); + expect(confirmPasswordInput).toHaveValue(''); + expect(confirmPasswordInput).toHaveAttribute('type', 'password'); + expect(confirmPasswordInput).not.toHaveAttribute('aria-invalid', 'true'); + expect( + screen.queryByText('Passwords do not match'), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /show password/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /show confirm password/i }), + ).toBeInTheDocument(); + }); + + it('resets all form state, visibility, and validation errors when closed and reopened', async () => { + const user = userEvent.setup(); + const { rerender } = renderDialog(true); + + await user.type(screen.getByLabelText(/album name/i), 'Secret Trip'); + await user.click(screen.getByLabelText(/lock album/i)); + await user.type(screen.getByLabelText(/^password \*/i), 'secret123'); + await user.type( + screen.getByLabelText(/confirm password \*/i), + 'mismatchedpass', + ); + + await user.click(screen.getByRole('button', { name: /show password/i })); + await user.click( + screen.getByRole('button', { name: /show confirm password/i }), + ); + + await user.click(screen.getByRole('button', { name: /create album/i })); + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + + rerender( + , + ); + + rerender( + , + ); + + expect(screen.getByLabelText(/album name \*/i)).toHaveValue(''); + expect( + screen.queryByText('Passwords do not match'), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^password/i)).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText(/lock album/i)); + const passwordInput = screen.getByLabelText(/^password \*/i); + const confirmPasswordInput = screen.getByLabelText(/confirm password \*/i); + expect(passwordInput).toHaveValue(''); + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).not.toHaveAttribute('aria-invalid', 'true'); + expect(confirmPasswordInput).toHaveValue(''); + expect(confirmPasswordInput).toHaveAttribute('type', 'password'); + expect(confirmPasswordInput).not.toHaveAttribute('aria-invalid', 'true'); + expect( + screen.queryByText('Passwords do not match'), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /show password/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /show confirm password/i }), + ).toBeInTheDocument(); + }); + + it('submits locked album successfully when passwords match', async () => { + const user = userEvent.setup(); + renderDialog(true, jest.fn(), jest.fn()); + + await user.type(screen.getByLabelText(/album name/i), 'Secret Album'); + await user.type(screen.getByLabelText(/description/i), 'Secret photos'); + await user.click(screen.getByLabelText(/lock album/i)); + await user.type(screen.getByLabelText(/^password \*/i), 'secret123'); + await user.type(screen.getByLabelText(/confirm password \*/i), 'secret123'); + + await user.click(screen.getByRole('button', { name: /create album/i })); + + await waitFor(() => { + expect(mockCreateAlbum).toHaveBeenCalledWith({ + name: 'Secret Album', + description: 'Secret photos', + is_locked: true, + password: 'secret123', + }); + }); + }); + + it('submits unlocked album without password payload', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.type(screen.getByLabelText(/album name/i), 'Public Album'); + await user.click(screen.getByRole('button', { name: /create album/i })); + + await waitFor(() => { + expect(mockCreateAlbum).toHaveBeenCalledWith({ + name: 'Public Album', + is_locked: false, + }); + }); + }); + + it('calls onClose when Cancel button is clicked', async () => { + const user = userEvent.setup(); + const handleClose = jest.fn(); + renderDialog(true, handleClose); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(handleClose).toHaveBeenCalledTimes(1); + }); +});