Skip to content

Commit 4671648

Browse files
authored
Merge pull request #54 from bartstc/feat/form
feat: add RHF for form state
2 parents a250473 + 57b7696 commit 4671648

43 files changed

Lines changed: 1838 additions & 250 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/building-blocks/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ A structured catalog of typed frontend building blocks, optimized for AI agents.
1010

1111
## Building Block Categories
1212

13-
| Category | Blocks | Description |
14-
| ------------------ | ------ | ------------------------------------------------------------------------------ |
15-
| Data Fetching | 4 | Query factories, mutations, keys, DTOs |
16-
| State Management | 2 | Zustand stores, React Context providers |
17-
| App Orchestration | 1 | Use case hooks composing mutations + notifications |
18-
| Component Patterns | 6 | Pure components, compound components, pages, HOCs, facade hooks, named effects |
19-
| Data Modeling | 2 | Frontend models, value objects |
13+
| Category | Blocks | Description |
14+
| ------------------ | ------ | ------------------------------------------------------------------------------------- |
15+
| Data Fetching | 4 | Query factories, mutations, keys, DTOs |
16+
| State Management | 2 | Zustand stores, React Context providers |
17+
| App Orchestration | 1 | Use case hooks composing mutations + notifications |
18+
| Component Patterns | 7 | Pure components, compound components, forms, pages, HOCs, facade hooks, named effects |
19+
| Data Modeling | 2 | Frontend models, value objects |
2020

2121
## How It Works
2222

.agents/skills/building-blocks/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Read the rule file for each block you are about to implement. The summaries belo
4242
| `notification-hook` | `application/` | Maps operation outcomes to toast notifications. One hook per use case. | `rules/notification-hook.md` |
4343
| `pure-component` | `components/` | Props in, JSX out. No side effects, no internal state beyond `useMemo`. | `rules/pure-component.md` |
4444
| `compound-component` | `components/` | Dot-notation sub-components. Composition over configuration — no boolean prop toggles. | `rules/compound-component.md` |
45+
| `form` | `components/` | Context-driven form via `useForm` + `FormProvider` + field components. | `rules/form.md` |
4546
| `hoc` | `components/` | Component in → enhanced component out. For render-level decisions (auth gates, suspense wrappers). | `rules/hoc.md` |
4647
| `page` | `pages/` | Route-level orchestrator. Only place where router coupling is acceptable. | `rules/page.md` |
4748
| `facade-hook` | `components/` | Private logic extraction for a single component. Defined below the component, not exported. | `rules/facade-hook.md` |
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
title: Form
3+
category: Component Patterns
4+
layer: components/
5+
composedWith: use-case-hook
6+
---
7+
8+
## Form
9+
10+
Context-driven form built on the project's `useForm` / `FormProvider` abstraction over react-hook-form. Use for any user input that submits data — creation forms, edit forms, settings panels, inline editors. Skip for single uncontrolled inputs (search bars, filters) that don't need validation or submit handling.
11+
12+
### Constraints
13+
14+
- ALWAYS add `noValidate` to the `<form>` element — without it, native browser validation fires before react-hook-form, blocking error messages
15+
- ALWAYS type the form with an explicit `interface` for field values — pass it as the generic to `useForm<MyValues>()`
16+
- Use `FormProvider` at the form root. Field components (`TextInput`, `SelectInput`, `NumberInput`, `MoneyInput`, `TextareaInput`) read `control` and `configuration` from context — no prop drilling
17+
- Use the `register` prop on field components for custom validation rules beyond `isRequired`
18+
- Wire `onSubmit` through `form.handleSubmit(handler)` — never call the handler directly
19+
- Set `configuration` in `useForm()` for form-wide visual settings (`size`, `variant`, `autoValidation`). Use `setConfiguration` only for runtime changes
20+
21+
### Conditional Fields
22+
23+
- MUST define conditional field components at **module scope** — never inside the parent component. Inline definitions cause React to remount the field on every parent re-render, destroying field state
24+
- Use `useFieldBasedCondition` to toggle visibility based on another field's value. It handles value caching (when `keepHiddenFieldValue: true`) and cleanup automatically
25+
- Use `useCondition` (lower-level) when you already have a derived boolean from outside the form
26+
27+
### Available Field Components
28+
29+
All fields accept: `name` (required), `label`, `isRequired`, `isDisabled`, `register`. They auto-display validation errors.
30+
31+
| Component | Stored type | Notes |
32+
| --------------- | ------------------ | -------------------------------------------------------------------------- |
33+
| `TextInput` | `string` | Supports `type` (`email`, `password`, etc.) |
34+
| `SelectInput` | `Value \| Value[]` | `isMulti` for multi-select, trigger is `<button role="combobox">` in tests |
35+
| `NumberInput` | `number \| null` | Chakra stepper + input |
36+
| `MoneyInput` | `number \| null` | Left addon with `symbol` prop (`$`, ``) |
37+
| `TextareaInput` | `string` | Same as TextInput minus `type` and `autofocus` |
38+
39+
### Example
40+
41+
```tsx
42+
// ✅ Correct — conditional field at module scope, noValidate, typed values
43+
interface ProductValues {
44+
name: string;
45+
price: number;
46+
hasCoupon: string;
47+
couponCode: string;
48+
}
49+
50+
const CouponField = () => {
51+
const isVisible = useFieldBasedCondition<ProductValues>("couponCode", {
52+
name: "hasCoupon",
53+
condition: (val) => (val as unknown as string) === "yes",
54+
defaultValue: "",
55+
});
56+
57+
if (!isVisible) return null;
58+
return <TextInput name="couponCode" label="Coupon Code" />;
59+
};
60+
61+
const CreateProductForm = ({
62+
onSubmit,
63+
}: {
64+
onSubmit: (v: ProductValues) => void;
65+
}) => {
66+
const form = useForm<ProductValues>({
67+
configuration: { autoValidation: true, size: "md" },
68+
});
69+
70+
return (
71+
<FormProvider {...form}>
72+
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
73+
<TextInput name="name" label="Product Name" isRequired />
74+
<MoneyInput name="price" label="Price" symbol="$" isRequired />
75+
<SelectInput
76+
name="hasCoupon"
77+
label="Has Coupon?"
78+
options={[
79+
{ label: "Yes", value: "yes" },
80+
{ label: "No", value: "no" },
81+
]}
82+
/>
83+
<CouponField />
84+
<button type="submit">Create</button>
85+
</form>
86+
</FormProvider>
87+
);
88+
};
89+
```
90+
91+
### Anti-Patterns
92+
93+
- **Prop-drilling control/configuration** — passing `control` or field config as props instead of using `FormProvider` context. Breaks the abstraction and creates coupling
94+
- **Skipping the `register` prop** — adding validation via raw `useController` or `useFormContext` when the field component already accepts `register` for custom rules
95+
- **Testing SelectInput with `getByLabelText`** — the select trigger is `<button role="combobox">`, use `getByRole("combobox", { name: "Label" })` instead
96+
97+
### References
98+
99+
- `@src/lib/components/Form/` — source for `useForm`, `FormProvider`, all field components
100+
- `@src/lib/components/Form/Form.mdx` — full API documentation with props tables

.claude/rules/architecture.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ paths:
1919
## Feature Slice Layers
2020

2121
- Each feature has four layers: `components/`, `application/`, `providers/`, `models/`
22-
- `components/` imports from `application/`, `models/`, and `providers/`
23-
- `application/` imports from `models/` and `providers/` — not from `components/`
24-
- `providers/` is the data access gateway: composes query hooks, mutations, loaders from `src/lib/api/`
22+
- `components/` imports from `application/`, `models/`, `providers/`, and `lib/*`
23+
- `application/` imports from `models/`, `providers/`, and `lib/*` — not from `components/`
24+
- `providers/` is the data access gateway: composes query hooks, mutations from `src/lib/api/`; may import from `models/`, `lib/api/`, `lib/*`
25+
- `models/` holds domain type definitions only — no logic; may import from `lib/api/` and `lib/*`
26+
- `lib/api/` must never be imported in `components/`, `application/`, or `pages/`
2527
- Library-specific code (React Query, etc.) stays inside `providers/` — never leak beyond this layer
2628
- API logic starts in `src/lib/api/` (queryOptions factories, mutations, DTOs by resource), then gets exposed through the relevant feature's `providers/`
2729
- Query files expose `queryOptions` factories — hook composition belongs in `providers/`, not in API files

.claude/skills/executing-plans

Lines changed: 0 additions & 1 deletion
This file was deleted.

.storybook/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { StorybookConfig } from "@storybook/react-vite";
22

33
const config: StorybookConfig = {
4-
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
4+
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
55

66
addons: [
77
"@storybook/addon-links",

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ An opinionated, production-ready starter for **Single Page Application** develop
2929
- [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) — lightweight state management
3030
- [i18next](https://www.i18next.com/) — internationalization
3131
- [XState](https://stately.ai/docs/xstate) — state orchestration (example usage)
32+
- [React Hook Form](https://react-hook-form.com) - form state management with light and composable abstraction
3233
- [MSW 2](https://mswjs.io/) — API mocking for development and tests
3334

3435
### Architecture

docs/spec-driven-development.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,13 @@ The building blocks catalog lives in `skills/building-blocks/`. It uses progress
208208

209209
### Block categories
210210

211-
| Category | Blocks | What they cover |
212-
| ------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
213-
| Data Fetching | `mutation-hook`, `query-options-factory`, `query-keys-factory`, `dto-model` | Server reads/writes, cache keys, API response types |
214-
| State Management | `store`, `provider` | Zustand stores, React Context dependency injection |
215-
| App Orchestration | `use-case-hook` | Feature-level operations composing mutations + notifications |
216-
| Component Patterns | `notification-hook`, `pure-component`, `compound-component`, `hoc`, `page`, `facade-hook`, `named-effect` | UI components, hooks, and composition patterns |
217-
| Data Modeling | `frontend-model`, `value-object` | Domain types, value-based logic grouping |
211+
| Category | Blocks | What they cover |
212+
| ------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
213+
| Data Fetching | `mutation-hook`, `query-options-factory`, `query-keys-factory`, `dto-model` | Server reads/writes, cache keys, API response types |
214+
| State Management | `store`, `provider` | Zustand stores, React Context dependency injection |
215+
| App Orchestration | `use-case-hook` | Feature-level operations composing mutations + notifications |
216+
| Component Patterns | `notification-hook`, `pure-component`, `compound-component`, `form`, `hoc`, `page`, `facade-hook`, `named-effect` | UI components, hooks, and composition patterns |
217+
| Data Modeling | `frontend-model`, `value-object` | Domain types, value-based logic grouping |
218218

219219
### How specs reference building blocks
220220

e2e/pages/cart/CartPage.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@ import type { Page, Locator } from "@playwright/test";
33
import type { PaymentMethod } from "@/features/carts/models/payment-method";
44
import { BasePage } from "@e2e/pages/base/BasePage";
55

6+
const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
7+
blik: "Blik",
8+
card: "Credit Card",
9+
paypal: "PayPal",
10+
};
11+
612
export class CartPage extends BasePage {
713
readonly checkoutButton: Locator;
814
readonly checkoutDialog: Locator;
915
private readonly fullNameInput: Locator;
1016
private readonly addressInput: Locator;
11-
private readonly paymentMethodSelect: Locator;
17+
private readonly paymentMethodTrigger: Locator;
1218
private readonly submitButton: Locator;
1319

1420
constructor(page: Page) {
@@ -20,8 +26,9 @@ export class CartPage extends BasePage {
2026

2127
this.fullNameInput = this.checkoutDialog.getByLabel(/full name/i);
2228
this.addressInput = this.checkoutDialog.getByLabel(/address/i);
23-
this.paymentMethodSelect =
24-
this.checkoutDialog.getByLabel(/payment method/i);
29+
this.paymentMethodTrigger = this.checkoutDialog.getByRole("combobox", {
30+
name: /payment method/i,
31+
});
2532
this.submitButton = this.checkoutDialog.getByRole("button", {
2633
name: /complete order/i,
2734
});
@@ -44,7 +51,10 @@ export class CartPage extends BasePage {
4451
): Promise<this> {
4552
await this.fullNameInput.fill(fullName);
4653
await this.addressInput.fill(address);
47-
await this.paymentMethodSelect.selectOption(paymentMethod);
54+
await this.paymentMethodTrigger.click();
55+
await this.page
56+
.getByRole("option", { name: PAYMENT_METHOD_LABELS[paymentMethod] })
57+
.click();
4858
return this;
4959
}
5060

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default defineConfig(
9191
"**/*.typegen.ts",
9292
"**/public/mockServiceWorker.js",
9393
"server/**",
94+
"**/*.mdx",
9495
],
9596
},
9697
js.configs.recommended,

0 commit comments

Comments
 (0)