diff --git a/src/app/invoice-form.tsx b/src/app/invoice-form.tsx index c6faf92..9648eea 100644 --- a/src/app/invoice-form.tsx +++ b/src/app/invoice-form.tsx @@ -4,9 +4,10 @@ import { PlusIcon, TrashIcon } from '@heroicons/react/16/solid'; import { zodResolver } from '@hookform/resolvers/zod'; import { useState } from 'react'; import CurrencyInput from 'react-currency-input-field'; -import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; +import { Controller, SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; import { InvoiceFormValues, invoiceSchema } from '@/app/validation'; +import { useFormStorage } from '@/hooks/useFormStorage'; import { Button } from '@/lib/ui/button'; import { Divider } from '@/lib/ui/divider'; import { Description, ErrorMessage, Field, FieldGroup, Fieldset, Label, Legend } from '@/lib/ui/fieldset'; @@ -24,22 +25,35 @@ const generateInvoiceId = () => { export function InvoiceForm() { const [isGenerating, setIsGenerating] = useState(false); + const defaultValues: InvoiceFormValues = { + company_name: '', + company_address: '', + bill_to: '', + bill_to_address: '', + currency: 'USD', + due_date: '', + vat_id: '', + services: [{ description: '', quantity: '1', amount: '' }], + notes: '', + }; + const { register, control, handleSubmit, watch, + getValues, + reset, formState: { errors }, } = useForm({ resolver: zodResolver(invoiceSchema), - defaultValues: { - currency: 'USD', - services: [{ description: '', quantity: '1', amount: '' }], - }, + defaultValues, }); const currency = watch('currency'); + const { hasSavedData, handleSave, handleLoad, handleClear } = useFormStorage(getValues, reset, defaultValues); + const { fields, append, remove } = useFieldArray({ control, name: 'services', @@ -172,18 +186,26 @@ export function InvoiceForm() { - ( + onChange(val ?? '')} + /> + )} /> {errors.services?.[index]?.amount && ( {errors.services[index]?.amount?.message} @@ -261,13 +283,24 @@ export function InvoiceForm() { - +
+ + + {hasSavedData && ( + <> + + + + )} +
); } diff --git a/src/hooks/useFormStorage.ts b/src/hooks/useFormStorage.ts new file mode 100644 index 0000000..2cf8e00 --- /dev/null +++ b/src/hooks/useFormStorage.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +import { InvoiceFormValues } from '@/app/validation'; + +const STORAGE_KEY = '@freeinvoice-form-data'; + +export function useFormStorage( + getValues: () => InvoiceFormValues, + reset: (values?: Partial) => void, + defaultValues: Partial +) { + const [hasSavedData, setHasSavedData] = useState(false); + + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + setHasSavedData(saved !== null); + } catch { + // localStorage unavailable + } + }, []); + + const handleSave = () => { + try { + const data = getValues(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + setHasSavedData(true); + } catch { + // localStorage unavailable + } + }; + + const handleLoad = () => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved) as InvoiceFormValues; + reset(parsed); + } + } catch { + // corrupted or unavailable + } + }; + + const handleClear = () => { + try { + localStorage.removeItem(STORAGE_KEY); + setHasSavedData(false); + reset(defaultValues); + } catch { + // localStorage unavailable + } + }; + + return { hasSavedData, handleSave, handleLoad, handleClear }; +} diff --git a/tests/form.spec.ts b/tests/form.spec.ts index 3a1835d..8feede0 100644 --- a/tests/form.spec.ts +++ b/tests/form.spec.ts @@ -27,7 +27,7 @@ test.describe('Invoice Generation', () => { await context.close(); }); - test.beforeEach(async ({ page, context }) => { + test.beforeEach(async ({ page }) => { // Navigate to the page after localStorage is set await page.goto('/app'); }); @@ -122,3 +122,138 @@ test.describe('Invoice Generation', () => { await expect(page.getByLabel('Item Description')).toHaveCount(2); }); }); + +test.describe('Save/Load/Clear Form Data', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/app'); + }); + + test('should save form data to localStorage', async ({ page }) => { + // Fill some fields + await page.getByLabel('Company Name').fill('Test Corp'); + await page.getByLabel('Company Address').fill('123 Test Street'); + + // Click Save + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + // Verify localStorage was set + const saved = await page.evaluate(() => localStorage.getItem('@freeinvoice-form-data')); + expect(saved).not.toBeNull(); + const parsed = JSON.parse(saved!); + expect(parsed.company_name).toBe('Test Corp'); + expect(parsed.company_address).toBe('123 Test Street'); + }); + + test('should show Load and Clear buttons after saving', async ({ page }) => { + // Initially, Load and Clear buttons should not be visible + await expect(page.getByRole('button', { name: 'Load Saved Data' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear Saved Data' })).not.toBeVisible(); + + // Fill and save + await page.getByLabel('Company Name').fill('Test Corp'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + // Now Load and Clear should be visible + await expect(page.getByRole('button', { name: 'Load Saved Data' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear Saved Data' })).toBeVisible(); + }); + + test('should show Load and Clear buttons when saved data exists on page load', async ({ page }) => { + // Pre-set localStorage + await page.evaluate(() => { + localStorage.setItem( + '@freeinvoice-form-data', + JSON.stringify({ + company_name: 'Saved Corp', + company_address: '456 Saved Ave', + bill_to: '', + bill_to_address: '', + currency: 'USD', + due_date: '', + services: [{ description: '', quantity: '1', amount: '' }], + }) + ); + }); + + // Reload to trigger useEffect + await page.reload(); + + // Load and Clear buttons should be visible + await expect(page.getByRole('button', { name: 'Load Saved Data' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear Saved Data' })).toBeVisible(); + + // Form should NOT be auto-populated (explicit load required) + await expect(page.getByLabel('Company Name')).toHaveValue(''); + }); + + test('should load saved data into the form when Load is clicked', async ({ page }) => { + // Pre-set localStorage with data + await page.evaluate(() => { + localStorage.setItem( + '@freeinvoice-form-data', + JSON.stringify({ + company_name: 'Loaded Corp', + company_address: '789 Loaded Blvd', + bill_to: 'Client X', + bill_to_address: '321 Client Rd', + currency: 'EUR', + due_date: '2026-06-15', + vat_id: 'EU999888777', + services: [{ description: 'Consulting', quantity: '5', amount: '200.00' }], + notes: 'Net 15 days', + }) + ); + }); + + await page.reload(); + + // Click Load + await page.getByRole('button', { name: 'Load Saved Data' }).click(); + + // Verify form fields are populated + await expect(page.getByLabel('Company Name')).toHaveValue('Loaded Corp'); + await expect(page.getByLabel('Company Address')).toHaveValue('789 Loaded Blvd'); + await expect(page.getByLabel('Client Name')).toHaveValue('Client X'); + await expect(page.getByLabel('Client Address')).toHaveValue('321 Client Rd'); + await expect(page.getByLabel('Currency')).toHaveValue('EUR'); + await expect(page.getByLabel('Due Date')).toHaveValue('2026-06-15'); + await expect(page.getByLabel('VAT ID')).toHaveValue('EU999888777'); + await expect(page.getByLabel('Item Description').first()).toHaveValue('Consulting'); + await expect(page.getByLabel('Quantity').first()).toHaveValue('5'); + await expect(page.getByLabel('Notes')).toHaveValue('Net 15 days'); + }); + + test('should clear saved data and reset form when Clear is clicked', async ({ page }) => { + // Fill and save + await page.getByLabel('Company Name').fill('Clear Me Corp'); + await page.getByLabel('Company Address').fill('999 Temp St'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + // Verify saved + await expect(page.getByRole('button', { name: 'Load Saved Data' })).toBeVisible(); + + // Click Clear + await page.getByRole('button', { name: 'Clear Saved Data' }).click(); + + // Load and Clear buttons should disappear + await expect(page.getByRole('button', { name: 'Load Saved Data' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear Saved Data' })).not.toBeVisible(); + + // Form should be reset to defaults + await expect(page.getByLabel('Company Name')).toHaveValue(''); + await expect(page.getByLabel('Company Address')).toHaveValue(''); + await expect(page.getByLabel('Currency')).toHaveValue('USD'); + + // localStorage should be empty + const saved = await page.evaluate(() => localStorage.getItem('@freeinvoice-form-data')); + expect(saved).toBeNull(); + }); + + test('should not interfere with Download PDF functionality', async ({ page }) => { + // Save button should not trigger form submission/validation + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + // No validation errors should appear (save doesn't validate) + await expect(page.getByText('Company name is required')).not.toBeVisible(); + }); +});