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;