diff --git a/packages/web/src/components/record-form.test.tsx b/packages/web/src/components/record-form.test.tsx new file mode 100644 index 0000000..ec299c0 --- /dev/null +++ b/packages/web/src/components/record-form.test.tsx @@ -0,0 +1,43 @@ +/// + +import {afterEach, describe, expect, mock, test} from 'bun:test'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import type {Field} from '@proxy/integrations'; +import {RecordForm} from '@/components/record-form'; + +const fields: Field[] = [{key: 'title', label: 'Title', type: 'text'}]; + +afterEach(() => { + cleanup(); +}); + +describe('RecordForm', () => { + test('keeps a required-non-empty submission disabled until a field has a value', () => { + const onSubmit = mock(); + render(); + + const save = screen.getByRole('button', {name: 'Save'}); + const title = screen.getByRole('textbox', {name: 'Title'}); + expect(save.hasAttribute('disabled')).toBe(true); + const form = title.closest('form'); + if (!form) { + throw new Error('RecordForm did not render a form'); + } + fireEvent.submit(form); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.change(title, {target: {value: ' '}}); + expect(save.hasAttribute('disabled')).toBe(true); + + fireEvent.change(title, {target: {value: 'Home'}}); + expect(save.hasAttribute('disabled')).toBe(false); + fireEvent.click(save); + expect(onSubmit).toHaveBeenCalledWith({title: 'Home'}); + }); + + test('keeps empty submission available unless the caller requires a value', () => { + render( undefined} />); + + expect(screen.getByRole('button', {name: 'Send'}).hasAttribute('disabled')).toBe(false); + }); +}); diff --git a/packages/web/src/components/record-form.tsx b/packages/web/src/components/record-form.tsx index 5b703fe..9c8a2e8 100644 --- a/packages/web/src/components/record-form.tsx +++ b/packages/web/src/components/record-form.tsx @@ -18,6 +18,8 @@ type FormProps = { initial?: Record; submitLabel: string; onSubmit: (values: Record) => void; + // Prevent creating a record until at least one visible field contains more than whitespace. + requireNonEmpty?: boolean; onCancel?: () => void; // Extra actions at the start of the button row, such as Delete. extra?: ReactNode; @@ -38,6 +40,7 @@ export function RecordForm({ initial = {}, submitLabel, onSubmit, + requireNonEmpty = false, onCancel, extra, alternative, @@ -46,6 +49,7 @@ export function RecordForm({ const [values, setValues] = useState(initial); const set = (key: string, value: string) => setValues((current) => ({...current, [key]: value})); const typedIn = fields.filter((field) => !field.system); + const empty = typedIn.every((field) => (values[field.key] ?? '').trim().length === 0); // Only what the form shows goes out: the fields a provider sets, such as an email's folder, // are never sent back. const submitted = () => @@ -56,6 +60,9 @@ export function RecordForm({ className="grid gap-4" onSubmit={(event) => { event.preventDefault(); + if (requireNonEmpty && empty) { + return; + } onSubmit(submitted()); }} > @@ -83,7 +90,9 @@ export function RecordForm({ {alternative.label} )} - + ); diff --git a/packages/web/src/pages/info-page.test.tsx b/packages/web/src/pages/info-page.test.tsx new file mode 100644 index 0000000..053cafb --- /dev/null +++ b/packages/web/src/pages/info-page.test.tsx @@ -0,0 +1,79 @@ +/// + +import {afterEach, describe, expect, test} from 'bun:test'; +import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import {TooltipProvider} from '@proxy/ui/components/tooltip'; +import {ThemeProvider} from '@proxy/ui/components/theme-provider'; +import {HumanSide} from '@/components/human-side'; +import {InfoPage} from '@/pages/info-page'; + +afterEach(() => { + cleanup(); +}); + +function renderInfoPage() { + const router = createMemoryRouter( + [ + { + id: 'human', + loader: () => ({ + user: {id: 'user-1', email: 'person@example.com', name: null, image: null}, + onboarded: true, + }), + element: , + children: [ + { + path: '/info', + loader: () => ({ + connectionId: 'info-1', + records: { + addresses: [{id: 'address-1', values: {}, updatedAt: '2026-10-09T00:00:00.000Z'}], + cards: [], + notes: [], + }, + }), + element: , + }, + ], + }, + ], + {initialEntries: ['/info']}, + ); + + return render( + + + + + , + ); +} + +function sectionNamed(name: string): HTMLElement { + const section = screen.getByRole('heading', {name}).closest('section'); + if (!section) { + throw new Error(`${name} did not render in a section`); + } + return section; +} + +describe('InfoPage', () => { + test('requires a value for every new Information record but not for an existing one', async () => { + renderInfoPage(); + await screen.findByRole('heading', {name: 'Addresses'}); + + for (const name of ['Addresses', 'Payment cards', 'Notes']) { + const section = sectionNamed(name); + fireEvent.click(within(section).getByRole('button', {name: 'Add'})); + expect(await screen.findByRole('button', {name: 'Save'})).toHaveProperty('disabled', true); + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + await waitFor(() => expect(screen.queryByRole('button', {name: 'Save'})).toBeNull()); + } + + fireEvent.click( + within(sectionNamed('Addresses')).getByRole('button', {name: 'Untitled address'}), + ); + expect(await screen.findByRole('button', {name: 'Save'})).toHaveProperty('disabled', false); + }); +}); diff --git a/packages/web/src/pages/info-page.tsx b/packages/web/src/pages/info-page.tsx index a2fca87..156beb2 100644 --- a/packages/web/src/pages/info-page.tsx +++ b/packages/web/src/pages/info-page.tsx @@ -103,6 +103,7 @@ export function InfoPage() { fields={editing.collection.fields} initial={editing.record?.values} submitLabel="Save" + requireNonEmpty={editing.record === null} onCancel={() => open(null)} onSubmit={(values) => { const {collection, record} = editing;