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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ tests/
- **Test wrapper**: `src/test-utils.tsx` provides `renderWithProviders` (QueryClient + MemoryRouter) and `createTestQueryClient`.
- **Mock `AuthProvider`** in component tests by mocking `"../AuthProvider"` with a `useAuthContext` that returns controlled values — avoids needing real Supabase auth.
- **Prefer testing RPC call shapes** over mocking Supabase query chains — RPC mocks are simpler (`vi.fn()` on `supabase.rpc`) and verify the contract with the database.
- **Use `userEvent.setup()`** for user interaction tests — call `const user = userEvent.setup()` then `await user.click(...)` / `await user.type(...)`. The legacy direct API (`userEvent.click(...)`) can cause timing issues.
- **Add `noValidate` to forms** — prevents browser constraint validation from blocking react-hook-form's schema validation in tests and in the app. Always add `noValidate` to any `<form>` that uses zodResolver.

### Required tests per feature

Every feature module must ship with:

| Test file | What to cover |
|-----------|---------------|
| `hooks/useFoo.test.tsx` | Query fetches data; mutation calls Supabase with correct payload (including camelCase→snake_case mapping); error propagation; cache invalidation |
| `components/FooForm.test.tsx` | Render in create vs edit mode; validation errors for empty/invalid fields; `mutateAsync` called with correct args; `onClose` called on success; Cancel button |
| `components/FooList.test.tsx` | Loading / error / empty states; row rendering; form toggle interactions; role guards if applicable |
| `tests/e2e/foo.spec.ts` | Full CRUD flow logged in as the correct role; access control (wrong role cannot access the page) |

Run `npm test && npm run lint && npm run build` before every commit.

## Supabase Guidelines

Expand All @@ -153,6 +168,7 @@ tests/
- Use database triggers (PL/pgSQL) for side effects (e.g., auto-creating profile on signup).
- Use Edge Functions for email webhook processing and sending.
- Migrations go in `supabase/migrations/` with descriptive names.
- **After writing any new migration, always run `npx supabase db reset` immediately** — do not wait for the user to ask. This applies the migration and re-seeds the local database so changes take effect right away.
- Run `supabase db push` to apply migrations locally, `supabase db reset` to reset.

### RLS Security Rules
Expand Down
37 changes: 37 additions & 0 deletions docs/WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Development Workflow

## One Task at a Time

Each plan task is implemented on its own branch, then submitted as a PR for review.

### Steps

1. **Create a branch** from the current base (last merged or last feature branch):
```bash
git checkout -b feat/taskN-<short-description>
```

2. **Implement the task** following the plan steps exactly.

3. **Verify** — run lint and build before committing:
```bash
npm run lint
npm run build
```

4. **Commit** with a descriptive message, then **open a PR** for agent review:
```bash
git push -u origin feat/taskN-<short-description>
gh pr create --title "..." --body "..."
```

5. **After PR is reviewed and merged**, start the next task from the updated base branch.

### Branch Naming

`feat/taskN-<kebab-case-description>` — e.g., `feat/task11-customers`

### PR Review

PRs should be reviewed by an agent (code-reviewer) before merging.
The PR body should summarize what was implemented and include a test plan.
142 changes: 142 additions & 0 deletions src/features/customers/components/CustomerForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/test-utils";
import { CustomerForm } from "./CustomerForm";
import type { Customer } from "@/lib/types";

const mockCreateMutateAsync = vi.fn();
const mockUpdateMutateAsync = vi.fn();

vi.mock("../hooks/useCustomers", () => ({
useCreateCustomer: () => ({
mutateAsync: mockCreateMutateAsync,
isPending: false,
error: null,
}),
useUpdateCustomer: () => ({
mutateAsync: mockUpdateMutateAsync,
isPending: false,
error: null,
}),
}));

const mockOnClose = vi.fn();
const COMPANY_ID = "co-1";

const EXISTING_CUSTOMER: Customer = {
id: "cust-1",
name: "Alice Smith",
email: "alice@acme.com",
company_id: COMPANY_ID,
created_at: "2026-01-01T00:00:00Z",
};

describe("CustomerForm — create mode", () => {
beforeEach(() => vi.clearAllMocks());

it("renders New Customer title with empty fields", () => {
renderWithProviders(
<CustomerForm companyId={COMPANY_ID} onClose={mockOnClose} />,
);
expect(screen.getByText("New Customer")).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("");
expect((screen.getByLabelText("Email") as HTMLInputElement).value).toBe("");
expect(screen.getByRole("button", { name: "Create" })).toBeTruthy();
});

it("shows validation error for empty name", async () => {
const user = userEvent.setup();
renderWithProviders(
<CustomerForm companyId={COMPANY_ID} onClose={mockOnClose} />,
);
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => expect(screen.getByText("Name is required")).toBeTruthy());
expect(mockCreateMutateAsync).not.toHaveBeenCalled();
});

it("shows validation error for invalid email", async () => {
const user = userEvent.setup();
renderWithProviders(
<CustomerForm companyId={COMPANY_ID} onClose={mockOnClose} />,
);
await user.type(screen.getByLabelText("Name"), "Bob");
await user.type(screen.getByLabelText("Email"), "not-an-email");
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(screen.getByText("Please enter a valid email")).toBeTruthy(),
);
expect(mockCreateMutateAsync).not.toHaveBeenCalled();
});

it("calls createCustomer.mutateAsync with correct args and then onClose", async () => {
const user = userEvent.setup();
mockCreateMutateAsync.mockResolvedValue({});
renderWithProviders(
<CustomerForm companyId={COMPANY_ID} onClose={mockOnClose} />,
);
await user.type(screen.getByLabelText("Name"), "Bob Jones");
await user.type(screen.getByLabelText("Email"), "bob@acme.com");
await user.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() =>
expect(mockCreateMutateAsync).toHaveBeenCalledWith({
name: "Bob Jones",
email: "bob@acme.com",
companyId: COMPANY_ID,
}),
);
expect(mockOnClose).toHaveBeenCalled();
});

it("calls onClose when Cancel is clicked without submitting", async () => {
const user = userEvent.setup();
renderWithProviders(
<CustomerForm companyId={COMPANY_ID} onClose={mockOnClose} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(mockOnClose).toHaveBeenCalled();
expect(mockCreateMutateAsync).not.toHaveBeenCalled();
});
});

describe("CustomerForm — edit mode", () => {
beforeEach(() => vi.clearAllMocks());

it("renders Edit Customer title with pre-filled fields", () => {
renderWithProviders(
<CustomerForm
companyId={COMPANY_ID}
customer={EXISTING_CUSTOMER}
onClose={mockOnClose}
/>,
);
expect(screen.getByText("Edit Customer")).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Alice Smith");
expect((screen.getByLabelText("Email") as HTMLInputElement).value).toBe("alice@acme.com");
expect(screen.getByRole("button", { name: "Save" })).toBeTruthy();
});

it("calls updateCustomer.mutateAsync with id, name, email — no companyId", async () => {
const user = userEvent.setup();
mockUpdateMutateAsync.mockResolvedValue({});
renderWithProviders(
<CustomerForm
companyId={COMPANY_ID}
customer={EXISTING_CUSTOMER}
onClose={mockOnClose}
/>,
);
const nameField = screen.getByLabelText("Name");
await user.clear(nameField);
await user.type(nameField, "Alice Updated");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(mockUpdateMutateAsync).toHaveBeenCalledWith({
id: "cust-1",
name: "Alice Updated",
email: "alice@acme.com",
}),
);
expect(mockOnClose).toHaveBeenCalled();
});
});
104 changes: 104 additions & 0 deletions src/features/customers/components/CustomerForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useCreateCustomer, useUpdateCustomer } from "../hooks/useCustomers";
import type { Customer } from "@/lib/types";

const customerSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Please enter a valid email"),
});

type CustomerValues = z.infer<typeof customerSchema>;

type CustomerFormProps = {
companyId: string;
customer?: Customer;
onClose: () => void;
};

export const CustomerForm = ({
companyId,
customer,
onClose,
}: CustomerFormProps) => {
const createCustomer = useCreateCustomer();
const updateCustomer = useUpdateCustomer();
const isEditing = !!customer;

const {
register,
handleSubmit,
formState: { errors },
} = useForm<CustomerValues>({
resolver: zodResolver(customerSchema),
defaultValues: customer
? { name: customer.name, email: customer.email }
: undefined,
});

const onSubmit = async (values: CustomerValues) => {
try {
if (isEditing) {
await updateCustomer.mutateAsync({
id: customer.id,
name: values.name,
email: values.email,
});
} else {
await createCustomer.mutateAsync({
name: values.name,
email: values.email,
companyId,
});
}
onClose();
} catch {
// error displayed via mutation.error below
}
};

const isPending = createCustomer.isPending || updateCustomer.isPending;
const mutationError = createCustomer.error ?? updateCustomer.error;

return (
<Card>
<CardHeader>
<CardTitle>{isEditing ? "Edit Customer" : "New Customer"}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="customer-name">Name</Label>
<Input id="customer-name" {...register("name")} />
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="customer-email">Email</Label>
<Input id="customer-email" type="email" {...register("email")} />
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
{mutationError && (
<p className="text-sm text-destructive">{mutationError.message}</p>
)}
<div className="flex gap-2">
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : isEditing ? "Save" : "Create"}
</Button>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
Loading
Loading