Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 57 additions & 24 deletions src/app/invoice-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<InvoiceFormValues>({
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',
Expand Down Expand Up @@ -172,18 +186,26 @@ export function InvoiceForm() {
</Field>
<Field className="md:w-64">
<Label htmlFor={`services.${index}.amount`}>Amount</Label>
<CurrencyInput
key={currency}
id={`services.${index}.amount`}
customInput={Input}
placeholder="e.g., 1500.00"
allowNegativeValue={false}
allowDecimals
intlConfig={{
locale: 'en-US',
currency: currency || 'USD',
}}
{...register(`services.${index}.amount`)}
<Controller
control={control}
name={`services.${index}.amount`}
render={({ field: { onChange, value, name } }) => (
<CurrencyInput
Comment on lines +189 to +193
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed to change to a controlled input, so it can be reset/loaded with different values, also in order to save only the numbers instead of the full string with currency code

key={currency}
id={`services.${index}.amount`}
name={name}
customInput={Input}
placeholder="e.g., 1500.00"
allowNegativeValue={false}
allowDecimals
intlConfig={{
locale: 'en-US',
currency: currency || 'USD',
}}
value={value}
onValueChange={(val) => onChange(val ?? '')}
/>
)}
/>
{errors.services?.[index]?.amount && (
<ErrorMessage>{errors.services[index]?.amount?.message}</ErrorMessage>
Expand Down Expand Up @@ -261,13 +283,24 @@ export function InvoiceForm() {
</Field>
</Fieldset>

<button
type="submit"
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 mt-10"
disabled={isGenerating}
>
Download PDF
</button>
<div className="flex flex-wrap gap-4 mt-10">
<Button type="submit" color="indigo" disabled={isGenerating}>
Comment on lines -264 to +287
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the styles are a little bit different, but as to me, it makes more sense to use the buttons defined in the UI, so they're all cohesive.

Download PDF
</Button>
<Button type="button" color="emerald" onClick={handleSave}>
Save
</Button>
{hasSavedData && (
<>
<Button type="button" color="zinc" onClick={handleLoad}>
Load Saved Data
</Button>
<Button type="button" color="red" onClick={handleClear}>
Clear Saved Data
</Button>
</>
)}
</div>
</form>
);
}
56 changes: 56 additions & 0 deletions src/hooks/useFormStorage.ts
Original file line number Diff line number Diff line change
@@ -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<InvoiceFormValues>) => void,
defaultValues: Partial<InvoiceFormValues>
) {
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);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't add any posthog event, but if you want, we can add it.

} 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 };
}
137 changes: 136 additions & 1 deletion tests/form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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();
});
});
Loading