Skip to content

Commit 936743e

Browse files
committed
Form refactors
1 parent 676b7e5 commit 936743e

9 files changed

Lines changed: 485 additions & 284 deletions

File tree

.cursor/rules/SHADES_COMPONENTS.mdc

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,100 @@ export const LoginForm = Shade({
210210
});
211211
```
212212

213+
## Form Handling
214+
215+
### Always use `Form<T>` from `@furystack/shades-common-components`
216+
217+
Never use raw `<form>` HTML elements. The `Form<T>` component provides type-safe form data collection, two-tier validation (input + form level), and integration with all form field components (`Input`, `Select`, `Checkbox`, etc.).
218+
219+
### Pattern: Typed Form Payload
220+
221+
Every form needs:
222+
223+
1. A **payload type** describing the form data shape
224+
2. A **type-guard `validate` function** that narrows `unknown` to the payload type
225+
3. A `Form<T>` component with `validate` and `onSubmit` props
226+
227+
```typescript
228+
import { Form, Input, Button } from '@furystack/shades-common-components';
229+
230+
type CreateStackPayload = {
231+
name: string;
232+
displayName: string;
233+
description: string;
234+
mainDirectory: string;
235+
};
236+
237+
const isCreateStackPayload = (data: unknown): data is CreateStackPayload => {
238+
const d = data as CreateStackPayload;
239+
return d.name?.length > 0 && d.displayName?.length > 0 && d.mainDirectory?.length > 0;
240+
};
241+
242+
// In render:
243+
<Form<CreateStackPayload>
244+
validate={isCreateStackPayload}
245+
onSubmit={(data) => {
246+
// `data` is fully typed as CreateStackPayload
247+
void handleSubmit(data);
248+
}}
249+
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
250+
>
251+
<Input name="name" labelTitle="Name" variant="outlined" required />
252+
<Input name="displayName" labelTitle="Display Name" variant="outlined" required />
253+
<Input name="description" labelTitle="Description" variant="outlined" />
254+
<Input name="mainDirectory" labelTitle="Main Directory" variant="outlined" required />
255+
<Button type="submit" variant="contained">Create</Button>
256+
</Form>
257+
```
258+
259+
### Use `Checkbox` component instead of raw `<input type="checkbox">`
260+
261+
```typescript
262+
import { Checkbox } from '@furystack/shades-common-components';
263+
264+
// ✅ Good - uses Checkbox component, integrates with Form<T>
265+
<Checkbox name="autoFetchEnabled" labelTitle="Auto-fetch" checked={initialValue} />
266+
267+
// ❌ Avoid - raw checkbox, no FormService integration
268+
<label>
269+
<input type="checkbox" name="autoFetchEnabled" checked={initialValue} />
270+
Auto-fetch
271+
</label>
272+
```
273+
274+
### Forbidden Patterns
275+
276+
Never use these patterns for form data handling:
277+
278+
```typescript
279+
// ❌ FORBIDDEN - raw <form> tag
280+
<form onsubmit={(ev) => { ... }}>
281+
282+
// ❌ FORBIDDEN - FormData extraction from DOM
283+
const formData = new FormData(ev.target as HTMLFormElement);
284+
const data = Object.fromEntries(formData.entries()) as Record<string, string>;
285+
286+
// ❌ FORBIDDEN - useRef to imperatively read form data
287+
const formRef = useRef<HTMLFormElement>('myForm');
288+
const data = new FormData(formRef.current);
289+
290+
// ❌ FORBIDDEN - direct DOM value access for form fields
291+
oninput={(ev) => setValue((ev.target as HTMLInputElement).value)}
292+
```
293+
294+
### Form Field Components
295+
296+
Use these components from `@furystack/shades-common-components` inside `Form<T>`:
297+
298+
- `Input` - Text, number, email, password fields
299+
- `Select` - Single/multi select dropdowns
300+
- `Checkbox` - Boolean checkboxes
301+
- `TextArea` - Multi-line text input
302+
- `Switch` - Toggle switches
303+
- `Radio` / `RadioGroup` - Radio button groups
304+
305+
All form field components automatically register with the parent `Form<T>` via the `FormService` and participate in validation.
306+
213307
## Theming
214308

215309
### Access Theme Properties
@@ -332,6 +426,7 @@ initializeShadeRoot({
332426
6. **useDisposable for cleanup** - Manage subscriptions properly
333427
7. **Use common components** - Leverage `@furystack/shades-common-components`
334428
8. **Consistent theming** - Use `ThemeProviderService` for styles
429+
9. **Use `Form<T>` for all forms** - Never use raw `<form>` tags, `FormData`, or `useRef` for form handling
335430

336431
**Component Checklist:**
337432

@@ -341,3 +436,4 @@ initializeShadeRoot({
341436
- [ ] Observable subscriptions use `useObservable`
342437
- [ ] Manual subscriptions cleaned up with `useDisposable`
343438
- [ ] Theme values from `ThemeProviderService`
439+
- [ ] Forms use `Form<T>` with typed payload and validate function

frontend/src/components/entity-forms/dependency-form.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { createComponent, Shade } from '@furystack/shades'
2-
import { Button, Input } from '@furystack/shades-common-components'
2+
import { Button, Form, Input } from '@furystack/shades-common-components'
33
import type { Dependency } from 'common'
44

5+
type DependencyFormPayload = {
6+
name: string
7+
checkCommand: string
8+
installationHelp: string
9+
}
10+
11+
const isDependencyFormPayload = (data: unknown): data is DependencyFormPayload => {
12+
const d = data as DependencyFormPayload
13+
return d.name?.length > 0 && d.checkCommand?.length > 0
14+
}
15+
516
type DependencyFormProps = {
617
initial?: Partial<Dependency>
718
stackName: string
@@ -14,19 +25,17 @@ export const DependencyForm = Shade<DependencyFormProps>({
1425
shadowDomName: 'shade-dependency-form',
1526
render: ({ props }) => {
1627
return (
17-
<form
18-
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
19-
onsubmit={async (ev) => {
20-
ev.preventDefault()
21-
const formData = new FormData(ev.target as HTMLFormElement)
22-
const data = Object.fromEntries(formData.entries()) as Record<string, string>
23-
await props.onSubmit({
28+
<Form<DependencyFormPayload>
29+
validate={isDependencyFormPayload}
30+
onSubmit={(data) =>
31+
void props.onSubmit({
2432
stackName: props.stackName,
2533
name: data.name,
2634
checkCommand: data.checkCommand,
2735
installationHelp: data.installationHelp,
2836
})
29-
}}
37+
}
38+
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
3039
>
3140
<h2 style={{ margin: '0' }}>{props.mode === 'create' ? 'Add Dependency' : 'Edit Dependency'}</h2>
3241
<Input
@@ -60,7 +69,7 @@ export const DependencyForm = Shade<DependencyFormProps>({
6069
{props.mode === 'create' ? 'Add' : 'Save'}
6170
</Button>
6271
</div>
63-
</form>
72+
</Form>
6473
)
6574
},
6675
})

frontend/src/components/entity-forms/github-repo-form.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { createComponent, NestedRouteLink, Shade } from '@furystack/shades'
2-
import { Button, Input } from '@furystack/shades-common-components'
2+
import { Button, Form, Input } from '@furystack/shades-common-components'
33
import type { GitHubRepository } from 'common'
44

5+
type GitHubRepoFormPayload = {
6+
url: string
7+
displayName: string
8+
description: string
9+
}
10+
11+
const isGitHubRepoFormPayload = (data: unknown): data is GitHubRepoFormPayload => {
12+
const d = data as GitHubRepoFormPayload
13+
return d.url?.length > 0 && d.displayName?.length > 0
14+
}
15+
516
type GitHubRepoFormProps = {
617
initial?: Partial<GitHubRepository>
718
stackName: string
@@ -15,19 +26,17 @@ export const GitHubRepoForm = Shade<GitHubRepoFormProps>({
1526
shadowDomName: 'shade-github-repo-form',
1627
render: ({ props }) => {
1728
return (
18-
<form
19-
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
20-
onsubmit={async (ev) => {
21-
ev.preventDefault()
22-
const formData = new FormData(ev.target as HTMLFormElement)
23-
const data = Object.fromEntries(formData.entries()) as Record<string, string>
24-
await props.onSubmit({
29+
<Form<GitHubRepoFormPayload>
30+
validate={isGitHubRepoFormPayload}
31+
onSubmit={(data) =>
32+
void props.onSubmit({
2533
stackName: props.stackName,
2634
url: data.url,
2735
displayName: data.displayName,
2836
description: data.description,
2937
})
30-
}}
38+
}
39+
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
3140
>
3241
<h2 style={{ margin: '0' }}>{props.mode === 'create' ? 'Add GitHub Repository' : 'Edit Repository'}</h2>
3342
<Input
@@ -65,7 +74,7 @@ export const GitHubRepoForm = Shade<GitHubRepoFormProps>({
6574
{props.mode === 'create' ? 'Add' : 'Save'}
6675
</Button>
6776
</div>
68-
</form>
77+
</Form>
6978
)
7079
},
7180
})

frontend/src/components/entity-forms/service-form.tsx

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
import { createComponent, NestedRouteLink, Shade } from '@furystack/shades'
2-
import { Button, Input, Select } from '@furystack/shades-common-components'
2+
import { Button, Checkbox, Form, Input, Select } from '@furystack/shades-common-components'
33
import type { GitHubRepository, ServiceView } from 'common'
44

5+
type ServiceFormPayload = {
6+
displayName: string
7+
description: string
8+
workingDirectory: string
9+
repositoryId: string
10+
runCommand: string
11+
installCommand: string
12+
buildCommand: string
13+
autoFetchEnabled: string
14+
autoFetchIntervalMinutes: string
15+
autoRestartOnFetch: string
16+
}
17+
18+
const isServiceFormPayload = (data: unknown): data is ServiceFormPayload => {
19+
const d = data as ServiceFormPayload
20+
return d.displayName?.length > 0 && d.runCommand?.length > 0
21+
}
22+
523
type ServiceFormProps = {
624
initial?: Partial<ServiceView>
725
stackName: string
@@ -16,13 +34,10 @@ export const ServiceForm = Shade<ServiceFormProps>({
1634
shadowDomName: 'shade-service-form',
1735
render: ({ props }) => {
1836
return (
19-
<form
20-
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
21-
onsubmit={async (ev) => {
22-
ev.preventDefault()
23-
const formData = new FormData(ev.target as HTMLFormElement)
24-
const data = Object.fromEntries(formData.entries()) as Record<string, string>
25-
await props.onSubmit({
37+
<Form<ServiceFormPayload>
38+
validate={isServiceFormPayload}
39+
onSubmit={(data) =>
40+
void props.onSubmit({
2641
stackName: props.stackName,
2742
displayName: data.displayName,
2843
description: data.description,
@@ -35,7 +50,8 @@ export const ServiceForm = Shade<ServiceFormProps>({
3550
autoFetchIntervalMinutes: parseInt(data.autoFetchIntervalMinutes, 10) || 60,
3651
autoRestartOnFetch: data.autoRestartOnFetch === 'on',
3752
})
38-
}}
53+
}
54+
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
3955
>
4056
<h2 style={{ margin: '0' }}>{props.mode === 'create' ? 'Create Service' : 'Edit Service'}</h2>
4157

@@ -100,10 +116,11 @@ export const ServiceForm = Shade<ServiceFormProps>({
100116
/>
101117
<h4 style={{ margin: '0', opacity: '0.7' }}>Configuration</h4>
102118
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
103-
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
104-
<input type="checkbox" name="autoFetchEnabled" checked={props.initial?.autoFetchEnabled ?? false} />
105-
Auto-fetch
106-
</label>
119+
<Checkbox
120+
name="autoFetchEnabled"
121+
labelTitle="Auto-fetch"
122+
checked={props.initial?.autoFetchEnabled ?? false}
123+
/>
107124
<Input
108125
name="autoFetchIntervalMinutes"
109126
labelTitle="Fetch interval (min)"
@@ -112,10 +129,11 @@ export const ServiceForm = Shade<ServiceFormProps>({
112129
value={String(props.initial?.autoFetchIntervalMinutes ?? 60)}
113130
style={{ width: '150px' }}
114131
/>
115-
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
116-
<input type="checkbox" name="autoRestartOnFetch" checked={props.initial?.autoRestartOnFetch ?? false} />
117-
Auto-restart on fetch
118-
</label>
132+
<Checkbox
133+
name="autoRestartOnFetch"
134+
labelTitle="Auto-restart on fetch"
135+
checked={props.initial?.autoRestartOnFetch ?? false}
136+
/>
119137
</div>
120138
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
121139
{props.cancelHref ? (
@@ -131,7 +149,7 @@ export const ServiceForm = Shade<ServiceFormProps>({
131149
{props.mode === 'create' ? 'Create' : 'Save'}
132150
</Button>
133151
</div>
134-
</form>
152+
</Form>
135153
)
136154
},
137155
})

frontend/src/components/entity-forms/stack-form.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import { createComponent, NestedRouteLink, Shade } from '@furystack/shades'
2-
import { Button, Input } from '@furystack/shades-common-components'
2+
import { Button, Form, Input } from '@furystack/shades-common-components'
33
import type { StackView } from 'common'
44

5+
type StackFormPayload = {
6+
name: string
7+
displayName: string
8+
description: string
9+
mainDirectory: string
10+
}
11+
12+
const isStackFormPayload = (data: unknown): data is StackFormPayload => {
13+
const d = data as StackFormPayload
14+
return d.name?.length > 0 && d.displayName?.length > 0 && d.mainDirectory?.length > 0
15+
}
16+
517
type StackFormProps = {
618
initial?: Partial<StackView>
719
onSubmit: (data: Partial<StackView>) => void | Promise<void>
@@ -14,19 +26,17 @@ export const StackForm = Shade<StackFormProps>({
1426
shadowDomName: 'shade-stack-form',
1527
render: ({ props }) => {
1628
return (
17-
<form
18-
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
19-
onsubmit={async (ev) => {
20-
ev.preventDefault()
21-
const formData = new FormData(ev.target as HTMLFormElement)
22-
const data = Object.fromEntries(formData.entries()) as Record<string, string>
23-
await props.onSubmit({
29+
<Form<StackFormPayload>
30+
validate={isStackFormPayload}
31+
onSubmit={(data) =>
32+
void props.onSubmit({
2433
name: data.name,
2534
displayName: data.displayName,
2635
description: data.description,
2736
mainDirectory: data.mainDirectory,
2837
})
29-
}}
38+
}
39+
style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }}
3040
>
3141
<h2 style={{ margin: '0' }}>{props.mode === 'create' ? 'Create Stack' : 'Edit Stack'}</h2>
3242

@@ -77,7 +87,7 @@ export const StackForm = Shade<StackFormProps>({
7787
{props.mode === 'create' ? 'Create' : 'Save'}
7888
</Button>
7989
</div>
80-
</form>
90+
</Form>
8191
)
8292
},
8393
})

0 commit comments

Comments
 (0)