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
43 changes: 43 additions & 0 deletions packages/web/src/components/record-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/// <reference types="bun" />

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(<RecordForm fields={fields} submitLabel="Save" requireNonEmpty onSubmit={onSubmit} />);

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(<RecordForm fields={fields} submitLabel="Send" onSubmit={() => undefined} />);

expect(screen.getByRole('button', {name: 'Send'}).hasAttribute('disabled')).toBe(false);
});
});
11 changes: 10 additions & 1 deletion packages/web/src/components/record-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type FormProps = {
initial?: Record<string, string>;
submitLabel: string;
onSubmit: (values: Record<string, string>) => 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;
Expand All @@ -38,6 +40,7 @@ export function RecordForm({
initial = {},
submitLabel,
onSubmit,
requireNonEmpty = false,
onCancel,
extra,
alternative,
Expand All @@ -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 = () =>
Expand All @@ -56,6 +60,9 @@ export function RecordForm({
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
if (requireNonEmpty && empty) {
return;
}
onSubmit(submitted());
}}
>
Expand Down Expand Up @@ -83,7 +90,9 @@ export function RecordForm({
{alternative.label}
</Button>
)}
<Button type="submit">{submitLabel}</Button>
<Button type="submit" disabled={requireNonEmpty && empty}>
{submitLabel}
</Button>
</div>
</form>
);
Expand Down
79 changes: 79 additions & 0 deletions packages/web/src/pages/info-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/// <reference types="bun" />

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: <HumanSide />,
children: [
{
path: '/info',
loader: () => ({
connectionId: 'info-1',
records: {
addresses: [{id: 'address-1', values: {}, updatedAt: '2026-10-09T00:00:00.000Z'}],
cards: [],
notes: [],
},
}),
element: <InfoPage />,
},
],
},
],
{initialEntries: ['/info']},
);

return render(
<ThemeProvider>
<TooltipProvider>
<RouterProvider router={router} />
</TooltipProvider>
</ThemeProvider>,
);
}

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);
});
});
1 change: 1 addition & 0 deletions packages/web/src/pages/info-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading