formcn 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/bin/index.js +71 -0
- package/generators/form-generator.js +152 -0
- package/generators/form-ui-templates.js +183 -0
- package/generators/multi-form-generator.js +257 -0
- package/generators/schema-generator.js +89 -0
- package/package.json +46 -0
- package/test/README.md +73 -0
- package/test/components.json +22 -0
- package/test/eslint.config.js +23 -0
- package/test/index.html +13 -0
- package/test/package-lock.json +4759 -0
- package/test/package.json +46 -0
- package/test/public/vite.svg +1 -0
- package/test/src/App.css +42 -0
- package/test/src/App.tsx +7 -0
- package/test/src/assets/react.svg +1 -0
- package/test/src/components/ui/button.tsx +62 -0
- package/test/src/components/ui/checkbox.tsx +32 -0
- package/test/src/components/ui/field.tsx +246 -0
- package/test/src/components/ui/input-group.tsx +170 -0
- package/test/src/components/ui/input.tsx +21 -0
- package/test/src/components/ui/label.tsx +22 -0
- package/test/src/components/ui/radio-group.tsx +43 -0
- package/test/src/components/ui/select.tsx +188 -0
- package/test/src/components/ui/separator.tsx +28 -0
- package/test/src/components/ui/textarea.tsx +18 -0
- package/test/src/index.css +123 -0
- package/test/src/lib/utils.ts +6 -0
- package/test/src/main.tsx +10 -0
- package/test/tsconfig.app.json +33 -0
- package/test/tsconfig.json +13 -0
- package/test/tsconfig.node.json +26 -0
- package/test/vite.config.ts +14 -0
- package/utils/ensurePackages.js +62 -0
- package/utils/lib.js +22 -0
- package/utils/prompts.js +200 -0
- package/utils/tailwind-presets.js +132 -0
- package/utils/templates.js +103 -0
- package/utils/test.js +136 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Fares Galal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# formcn
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/formcn)
|
|
4
|
+
[](https://www.npmjs.com/package/formcn)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
> Schema-driven React form generator built on top of React Hook Form, Zod, and shadcn/ui.
|
|
8
|
+
|
|
9
|
+
formcn generates fully typed, validated React form components from an interactive CLI workflow.
|
|
10
|
+
It is designed to reduce boilerplate while staying aligned with shadcn/ui conventions.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Zero-configuration form generation
|
|
17
|
+
- Type-safe schemas using Zod
|
|
18
|
+
- shadcn/ui component integration
|
|
19
|
+
- Single-step and multi-step forms
|
|
20
|
+
- Schema-first validation
|
|
21
|
+
- Interactive CLI workflow
|
|
22
|
+
- Automatic dependency detection and installation
|
|
23
|
+
- Predefined templates for common use cases
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Prerequisites
|
|
28
|
+
|
|
29
|
+
Before using formcn, ensure you have:
|
|
30
|
+
|
|
31
|
+
- Node.js 18+
|
|
32
|
+
- A React project with TypeScript
|
|
33
|
+
- shadcn/ui initialized in your project
|
|
34
|
+
|
|
35
|
+
Initialize shadcn/ui if needed:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx shadcn@latest init
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Install globally using npm:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install -g formcn
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or using yarn:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
yarn global add formcn
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or run directly with npx:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx formcn
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
Run the CLI:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
formcn
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The interactive workflow will guide you through:
|
|
74
|
+
|
|
75
|
+
1. Form name
|
|
76
|
+
2. Form type (single-step or multi-step)
|
|
77
|
+
3. Template selection
|
|
78
|
+
4. Field definitions and validation rules
|
|
79
|
+
5. Style preset selection
|
|
80
|
+
|
|
81
|
+
### Example
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
$ formcn
|
|
85
|
+
|
|
86
|
+
formcn
|
|
87
|
+
|
|
88
|
+
✔ react-hook-form detected
|
|
89
|
+
✔ @hookform/resolvers detected
|
|
90
|
+
✔ zod detected
|
|
91
|
+
✔ shadcn/ui components detected
|
|
92
|
+
|
|
93
|
+
Form name: register
|
|
94
|
+
Form type: single step
|
|
95
|
+
Template: registration
|
|
96
|
+
Preset: default
|
|
97
|
+
|
|
98
|
+
✔ Form generated at src/components/forms/register
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Generated Output
|
|
104
|
+
|
|
105
|
+
### Single-step form
|
|
106
|
+
|
|
107
|
+
```txt
|
|
108
|
+
src/components/forms/{formName}/
|
|
109
|
+
├── schema.ts
|
|
110
|
+
└── form.tsx
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Multi-step form
|
|
114
|
+
|
|
115
|
+
```txt
|
|
116
|
+
src/components/forms/user-registration/
|
|
117
|
+
├── personal-info-schema.ts
|
|
118
|
+
├── personalInfoStep.tsx
|
|
119
|
+
├── account-details-schema.ts
|
|
120
|
+
├── accountDetailsStep.tsx
|
|
121
|
+
├── contact-info-schema.ts
|
|
122
|
+
├── contactInfoStep.tsx
|
|
123
|
+
└── UserRegistrationForm.tsx
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Supported Field Types
|
|
129
|
+
|
|
130
|
+
- Text
|
|
131
|
+
- Email
|
|
132
|
+
- Password (with optional confirmation)
|
|
133
|
+
- Number
|
|
134
|
+
- URL
|
|
135
|
+
- Textarea
|
|
136
|
+
- Select
|
|
137
|
+
- Checkbox
|
|
138
|
+
- Radio
|
|
139
|
+
- Date
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Templates
|
|
144
|
+
|
|
145
|
+
### Single-step
|
|
146
|
+
|
|
147
|
+
- Registration
|
|
148
|
+
- Login
|
|
149
|
+
- Contact
|
|
150
|
+
|
|
151
|
+
### Multi-step
|
|
152
|
+
|
|
153
|
+
- Registration (personal info, account details, contact info)
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Style Presets
|
|
158
|
+
|
|
159
|
+
### Single-step
|
|
160
|
+
|
|
161
|
+
- `default` — minimal layout with subtle borders and spacing
|
|
162
|
+
|
|
163
|
+
### Multi-step
|
|
164
|
+
|
|
165
|
+
- `minimal`
|
|
166
|
+
- `sidebarStepper`
|
|
167
|
+
- `softType`
|
|
168
|
+
- `stepperTop`
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Example Output
|
|
173
|
+
|
|
174
|
+
### Schema (`schema.ts`)
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { z } from "zod";
|
|
178
|
+
|
|
179
|
+
export const schema = z
|
|
180
|
+
.object({
|
|
181
|
+
first_name: z.string().min(1, "First Name is required"),
|
|
182
|
+
last_name: z.string().min(1, "Last Name is required"),
|
|
183
|
+
email: z.string().email("Invalid email"),
|
|
184
|
+
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
185
|
+
passwordConfirmation: z.string(),
|
|
186
|
+
age: z.coerce.number().min(18, "Age must be at least 18"),
|
|
187
|
+
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
|
188
|
+
bio: z.string().optional(),
|
|
189
|
+
country: z.union([z.literal("us"), z.literal("ca"), z.literal("uk")], {
|
|
190
|
+
error: "Please select a country",
|
|
191
|
+
}),
|
|
192
|
+
newsletter: z.boolean().optional(),
|
|
193
|
+
})
|
|
194
|
+
.refine((data) => data.password === data.passwordConfirmation, {
|
|
195
|
+
message: "Passwords do not match",
|
|
196
|
+
path: ["passwordConfirmation"],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export type SchemaFormValues = z.infer<typeof schema>;
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Form (`form.tsx`)
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
"use client";
|
|
206
|
+
|
|
207
|
+
import { useForm } from "react-hook-form";
|
|
208
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
209
|
+
import { Button } from "@/components/ui/button";
|
|
210
|
+
import { FieldGroup } from "@/components/ui/field";
|
|
211
|
+
import { schema, type SchemaFormValues } from "./schema";
|
|
212
|
+
|
|
213
|
+
export default function RegisterForm() {
|
|
214
|
+
const form = useForm<SchemaFormValues>({
|
|
215
|
+
resolver: zodResolver(schema),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
function onSubmit(values: SchemaFormValues) {
|
|
219
|
+
console.log(values);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<form
|
|
224
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
225
|
+
className="space-y-6 max-w-3xl mx-auto border rounded-lg p-6"
|
|
226
|
+
>
|
|
227
|
+
<FieldGroup />
|
|
228
|
+
<div className="flex justify-end">
|
|
229
|
+
<Button type="submit" disabled={!form.formState.isValid}>
|
|
230
|
+
Submit
|
|
231
|
+
</Button>
|
|
232
|
+
</div>
|
|
233
|
+
</form>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Troubleshooting
|
|
241
|
+
|
|
242
|
+
### Command not found
|
|
243
|
+
|
|
244
|
+
Ensure your global npm bin directory is in your PATH:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npm config get prefix
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### shadcn/ui not detected
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npx shadcn@latest init
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Missing dependencies
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
npm install react-hook-form @hookform/resolvers zod
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
Contributions are welcome.
|
|
267
|
+
|
|
268
|
+
1. Fork the repository
|
|
269
|
+
2. Create a feature branch
|
|
270
|
+
3. Commit your changes
|
|
271
|
+
4. Push the branch
|
|
272
|
+
5. Open a pull request
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT License
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Author
|
|
283
|
+
|
|
284
|
+
**Fares Galal**
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Acknowledgments
|
|
289
|
+
|
|
290
|
+
- React Hook Form
|
|
291
|
+
- Zod
|
|
292
|
+
- shadcn/ui
|
|
293
|
+
- Tailwind CSS
|
|
294
|
+
- TypeScript
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { intro, outro, select } from "@clack/prompts";
|
|
2
|
+
import {
|
|
3
|
+
askFormType,
|
|
4
|
+
askFields,
|
|
5
|
+
askFormName,
|
|
6
|
+
askSteps,
|
|
7
|
+
askFormTemplate,
|
|
8
|
+
askFormPreset,
|
|
9
|
+
} from "../utils/prompts.js";
|
|
10
|
+
import { generateSingleForm } from "../generators/form-generator.js";
|
|
11
|
+
import { generateMultiForm } from "../generators/multi-form-generator.js";
|
|
12
|
+
import {
|
|
13
|
+
singleFormPresets,
|
|
14
|
+
multiFormPresets,
|
|
15
|
+
} from "../utils/tailwind-presets.js";
|
|
16
|
+
import {
|
|
17
|
+
SINGLE_FIELD_TEMPLATES,
|
|
18
|
+
MULTI_STEP_TEMPLATES,
|
|
19
|
+
} from "../utils/templates.js";
|
|
20
|
+
import { ensurePackages } from "../utils/ensurePackages.js";
|
|
21
|
+
|
|
22
|
+
intro("formcn");
|
|
23
|
+
|
|
24
|
+
await ensurePackages();
|
|
25
|
+
|
|
26
|
+
const formName = await askFormName();
|
|
27
|
+
const formType = await askFormType();
|
|
28
|
+
|
|
29
|
+
const formTemplateChoice = await askFormTemplate(formType);
|
|
30
|
+
|
|
31
|
+
let presetKey;
|
|
32
|
+
|
|
33
|
+
if (formTemplateChoice === "template") {
|
|
34
|
+
if (formType === "single") {
|
|
35
|
+
const templateKeys = Object.keys(SINGLE_FIELD_TEMPLATES);
|
|
36
|
+
const templateChoice = await select({
|
|
37
|
+
message: "Choose a template:",
|
|
38
|
+
options: templateKeys.map((key) => ({ value: key, label: key })),
|
|
39
|
+
});
|
|
40
|
+
presetKey = await askFormPreset(singleFormPresets);
|
|
41
|
+
await generateSingleForm({
|
|
42
|
+
formName,
|
|
43
|
+
fields: SINGLE_FIELD_TEMPLATES[templateChoice],
|
|
44
|
+
presetKey,
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
const templateKeys = Object.keys(MULTI_STEP_TEMPLATES);
|
|
48
|
+
const templateChoice = await select({
|
|
49
|
+
message: "Choose a template:",
|
|
50
|
+
options: templateKeys.map((key) => ({ value: key, label: key })),
|
|
51
|
+
});
|
|
52
|
+
presetKey = await askFormPreset(multiFormPresets);
|
|
53
|
+
await generateMultiForm({
|
|
54
|
+
formName,
|
|
55
|
+
steps: MULTI_STEP_TEMPLATES[templateChoice],
|
|
56
|
+
presetKey,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
if (formType === "single") {
|
|
61
|
+
const fields = await askFields();
|
|
62
|
+
presetKey = await askFormPreset(singleFormPresets);
|
|
63
|
+
await generateSingleForm({ formName, fields, presetKey });
|
|
64
|
+
} else {
|
|
65
|
+
presetKey = await askFormPreset(multiFormPresets);
|
|
66
|
+
const steps = await askSteps();
|
|
67
|
+
await generateMultiForm({ formName, steps, presetKey });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
outro("✅ Form created successfully");
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { generateZodSchema } from "./schema-generator.js";
|
|
4
|
+
import { toPascalCaseForm } from "../utils/lib.js";
|
|
5
|
+
import {
|
|
6
|
+
generateImports,
|
|
7
|
+
getComponentUsage,
|
|
8
|
+
getDefaultValue,
|
|
9
|
+
renderError,
|
|
10
|
+
renderInputByType,
|
|
11
|
+
} from "./form-ui-templates.js";
|
|
12
|
+
import { singleFormPresets } from "../utils/tailwind-presets.js";
|
|
13
|
+
import { confirm, isCancel } from "@clack/prompts";
|
|
14
|
+
|
|
15
|
+
export async function generateSingleForm({ formName, fields, presetKey }) {
|
|
16
|
+
const baseDir = fs.existsSync(path.resolve("src"))
|
|
17
|
+
? path.resolve("src/components/forms")
|
|
18
|
+
: path.resolve("components/forms");
|
|
19
|
+
|
|
20
|
+
const formDir = path.join(baseDir, formName);
|
|
21
|
+
|
|
22
|
+
const exists = await fs.pathExists(formDir);
|
|
23
|
+
|
|
24
|
+
if (exists) {
|
|
25
|
+
const overwrite = await confirm({
|
|
26
|
+
message: `Form "${formName}" already exists. Overwrite it?`,
|
|
27
|
+
initialValue: false,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (isCancel(overwrite) || !overwrite) {
|
|
31
|
+
console.log("❌ Operation cancelled.");
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
await fs.remove(formDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await fs.ensureDir(formDir);
|
|
39
|
+
|
|
40
|
+
const enhancedFields = addPasswordConfirmation(fields);
|
|
41
|
+
|
|
42
|
+
await fs.writeFile(
|
|
43
|
+
path.join(formDir, "schema.ts"),
|
|
44
|
+
generateZodSchema(enhancedFields)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
await fs.writeFile(
|
|
48
|
+
path.join(formDir, "form.tsx"),
|
|
49
|
+
generateFormComponent(formName, enhancedFields, presetKey)
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
console.log(`✅ Form generated at ${formDir}`);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await fs.remove(formDir);
|
|
55
|
+
console.error("❌ Failed to generate form. Changes reverted.");
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function addPasswordConfirmation(fields) {
|
|
61
|
+
return fields.flatMap((field) =>
|
|
62
|
+
field.type === "password" && field.confirm
|
|
63
|
+
? [
|
|
64
|
+
field,
|
|
65
|
+
{
|
|
66
|
+
...field,
|
|
67
|
+
name: `${field.name}Confirmation`,
|
|
68
|
+
label: "Confirm Password",
|
|
69
|
+
isConfirmation: true,
|
|
70
|
+
confirmationFor: field.name,
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
: field
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function generateFormComponent(formName, fields, presetKey) {
|
|
78
|
+
const uses = getComponentUsage(fields);
|
|
79
|
+
const componentName = toPascalCaseForm(formName);
|
|
80
|
+
const preset = singleFormPresets[presetKey];
|
|
81
|
+
if (!preset) throw new Error(`Single form preset "${presetKey}" not found`);
|
|
82
|
+
|
|
83
|
+
const renderControllerContent = (f) => `
|
|
84
|
+
<FieldLabel htmlFor="${f.name}">${f.label}${
|
|
85
|
+
f.required ? ' <span className="text-red-500">*</span>' : ""
|
|
86
|
+
}
|
|
87
|
+
</FieldLabel>
|
|
88
|
+
${renderInputByType(f)}
|
|
89
|
+
${renderError()}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const renderFields = fields
|
|
93
|
+
.map((f) => {
|
|
94
|
+
const isCheckbox = f.type === "checkbox";
|
|
95
|
+
|
|
96
|
+
return `
|
|
97
|
+
<Controller
|
|
98
|
+
name="${f.name}"
|
|
99
|
+
control={form.control}
|
|
100
|
+
render={({ field, fieldState }) => (
|
|
101
|
+
${
|
|
102
|
+
isCheckbox
|
|
103
|
+
? `
|
|
104
|
+
<>
|
|
105
|
+
${renderControllerContent(f, false)}
|
|
106
|
+
</>
|
|
107
|
+
`
|
|
108
|
+
: `
|
|
109
|
+
<Field data-invalid={fieldState.invalid}>
|
|
110
|
+
${renderControllerContent(f)}
|
|
111
|
+
</Field>
|
|
112
|
+
`
|
|
113
|
+
}
|
|
114
|
+
)}
|
|
115
|
+
/>
|
|
116
|
+
`;
|
|
117
|
+
})
|
|
118
|
+
.join("\n");
|
|
119
|
+
|
|
120
|
+
return `
|
|
121
|
+
"use client";
|
|
122
|
+
|
|
123
|
+
${generateImports(uses)}
|
|
124
|
+
import { schema, type SchemaFormValues } from "./schema";
|
|
125
|
+
|
|
126
|
+
export default function ${componentName}() {
|
|
127
|
+
const form = useForm<SchemaFormValues>({
|
|
128
|
+
resolver: zodResolver(schema),
|
|
129
|
+
defaultValues: {
|
|
130
|
+
${fields.map(getDefaultValue).join(",\n ")}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function onSubmit(data: SchemaFormValues) {
|
|
135
|
+
console.log(data);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="${preset.form}">
|
|
140
|
+
<FieldGroup>
|
|
141
|
+
${renderFields}
|
|
142
|
+
</FieldGroup>
|
|
143
|
+
<div className="${preset.buttonsWrapper}">
|
|
144
|
+
<Button type="submit" disabled={!form.formState.isValid}>
|
|
145
|
+
Submit
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
</form>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
`.trim();
|
|
152
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
export const INPUT_TEMPLATES = {
|
|
2
|
+
textarea: (f) => `<Textarea
|
|
3
|
+
{...field}
|
|
4
|
+
id="${f.name}"
|
|
5
|
+
rows={4}
|
|
6
|
+
${f.required ? "required" : ""}
|
|
7
|
+
aria-invalid={fieldState.invalid}
|
|
8
|
+
/>`,
|
|
9
|
+
|
|
10
|
+
select: (
|
|
11
|
+
f
|
|
12
|
+
) => `<Select value={field.value ?? ""} onValueChange={field.onChange}>
|
|
13
|
+
<SelectTrigger id="${f.name}" aria-invalid={fieldState.invalid}>
|
|
14
|
+
<SelectValue placeholder="Select ${f.label}" />
|
|
15
|
+
</SelectTrigger>
|
|
16
|
+
<SelectContent>
|
|
17
|
+
${f.options
|
|
18
|
+
.map(
|
|
19
|
+
(opt) => `<SelectItem value="${opt.value}">${opt.label}</SelectItem>`
|
|
20
|
+
)
|
|
21
|
+
.join("\n ")}
|
|
22
|
+
</SelectContent>
|
|
23
|
+
</Select>`,
|
|
24
|
+
|
|
25
|
+
checkbox: (f) => `<FieldGroup data-slot="checkbox-group">
|
|
26
|
+
<Field orientation="horizontal">
|
|
27
|
+
<Checkbox
|
|
28
|
+
id="${f.name}"
|
|
29
|
+
name={field.name}
|
|
30
|
+
checked={field.value}
|
|
31
|
+
onCheckedChange={field.onChange}
|
|
32
|
+
${f.required ? "required" : ""}
|
|
33
|
+
/>
|
|
34
|
+
<FieldLabel htmlFor="${f.name}" className="font-normal">
|
|
35
|
+
${f.label}
|
|
36
|
+
</FieldLabel>
|
|
37
|
+
</Field>
|
|
38
|
+
</FieldGroup>`,
|
|
39
|
+
|
|
40
|
+
radio: (
|
|
41
|
+
f
|
|
42
|
+
) => `<RadioGroup value={field.value ?? ""} onValueChange={(val) => field.onChange(val === "" ? undefined : val)}>
|
|
43
|
+
${f.options
|
|
44
|
+
.map(
|
|
45
|
+
(opt) => `<Field orientation="horizontal">
|
|
46
|
+
<RadioGroupItem value="${opt.value}" id="${f.name}-${opt.value}" />
|
|
47
|
+
<FieldLabel htmlFor="${f.name}-${opt.value}">${opt.label}</FieldLabel>
|
|
48
|
+
</Field>`
|
|
49
|
+
)
|
|
50
|
+
.join("\n ")}
|
|
51
|
+
</RadioGroup>`,
|
|
52
|
+
|
|
53
|
+
number: (f) => `<Input
|
|
54
|
+
id="${f.name}"
|
|
55
|
+
type="number"
|
|
56
|
+
autoComplete="${f.autocomplete ?? "off"}"
|
|
57
|
+
${f.required ? "required" : ""}
|
|
58
|
+
aria-invalid={fieldState.invalid}
|
|
59
|
+
value={field.value ?? ""}
|
|
60
|
+
onChange={(e) => field.onChange(e.target.value === "" ? undefined : Number(e.target.value))}
|
|
61
|
+
onBlur={field.onBlur}
|
|
62
|
+
ref={field.ref}
|
|
63
|
+
/>`,
|
|
64
|
+
|
|
65
|
+
default: (f) => {
|
|
66
|
+
const type = ["text", "email", "password", "date"].includes(f.type)
|
|
67
|
+
? f.type
|
|
68
|
+
: "text";
|
|
69
|
+
const defaultAutoComplete = {
|
|
70
|
+
text: "off",
|
|
71
|
+
email: "email",
|
|
72
|
+
password: "new-password",
|
|
73
|
+
date: "bday",
|
|
74
|
+
}[type];
|
|
75
|
+
return `<Input
|
|
76
|
+
{...field}
|
|
77
|
+
id="${f.name}"
|
|
78
|
+
type="${type}"
|
|
79
|
+
autoComplete="${f.autocomplete ?? defaultAutoComplete}"
|
|
80
|
+
${f.required ? "required" : ""}
|
|
81
|
+
aria-invalid={fieldState.invalid}
|
|
82
|
+
/>`;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const COMPONENT_USAGE_MAP = {
|
|
87
|
+
textarea: { Textarea: true },
|
|
88
|
+
select: {
|
|
89
|
+
Select: true,
|
|
90
|
+
SelectTrigger: true,
|
|
91
|
+
SelectValue: true,
|
|
92
|
+
SelectContent: true,
|
|
93
|
+
SelectItem: true,
|
|
94
|
+
},
|
|
95
|
+
checkbox: { Checkbox: true },
|
|
96
|
+
radio: { RadioGroup: true, RadioGroupItem: true },
|
|
97
|
+
default: { Input: true },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function renderInputByType(f) {
|
|
101
|
+
return INPUT_TEMPLATES[f.type]?.(f) || INPUT_TEMPLATES.default(f);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getComponentUsage(fields) {
|
|
105
|
+
const usage = {};
|
|
106
|
+
|
|
107
|
+
fields.forEach((f) => {
|
|
108
|
+
const components =
|
|
109
|
+
COMPONENT_USAGE_MAP[f.type] || COMPONENT_USAGE_MAP.default;
|
|
110
|
+
Object.assign(usage, components);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return usage;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function generateImports(uses = {}, mode = "single") {
|
|
117
|
+
const importSet = new Set();
|
|
118
|
+
|
|
119
|
+
const componentImports = {
|
|
120
|
+
Input: `import { Input } from "@/components/ui/input";`,
|
|
121
|
+
Textarea: `import { Textarea } from "@/components/ui/textarea";`, // <--- add this
|
|
122
|
+
Select: `import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";`,
|
|
123
|
+
Checkbox: `import { Checkbox } from "@/components/ui/checkbox";`,
|
|
124
|
+
RadioGroup: `import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";`,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (mode === "single") {
|
|
128
|
+
importSet.add(`import { useForm, Controller } from "react-hook-form";`);
|
|
129
|
+
importSet.add(`import { Button } from "@/components/ui/button";`);
|
|
130
|
+
importSet.add(`import { zodResolver } from "@hookform/resolvers/zod";`);
|
|
131
|
+
importSet.add(
|
|
132
|
+
`import { Field, FieldLabel, FieldError, FieldGroup } from "@/components/ui/field";`
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
Object.entries(uses).forEach(([component, used]) => {
|
|
136
|
+
if (used && componentImports[component])
|
|
137
|
+
importSet.add(componentImports[component]);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (mode === "step") {
|
|
142
|
+
importSet.add(
|
|
143
|
+
`import { Controller, useFormContext } from "react-hook-form";`
|
|
144
|
+
);
|
|
145
|
+
importSet.add(
|
|
146
|
+
`import { Field, FieldLabel, FieldError, FieldGroup } from "@/components/ui/field";`
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
Object.entries(uses).forEach(([component, used]) => {
|
|
150
|
+
if (used && componentImports[component])
|
|
151
|
+
importSet.add(componentImports[component]);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (mode === "multi") {
|
|
156
|
+
importSet.add(`import { useForm, FormProvider } from "react-hook-form";`);
|
|
157
|
+
importSet.add(`import { Button } from "@/components/ui/button";`);
|
|
158
|
+
importSet.add(`import { zodResolver } from "@hookform/resolvers/zod";`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Array.from(importSet).join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getDefaultValue(field) {
|
|
165
|
+
if (field.type === "checkbox") return `${field.name}: false`;
|
|
166
|
+
if (
|
|
167
|
+
field.type === "number" ||
|
|
168
|
+
field.type === "select" ||
|
|
169
|
+
field.type === "radio"
|
|
170
|
+
)
|
|
171
|
+
return `${field.name}: undefined`;
|
|
172
|
+
return `${field.name}: ""`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function renderError() {
|
|
176
|
+
return `
|
|
177
|
+
{fieldState.invalid && (
|
|
178
|
+
<FieldError className="text-left">
|
|
179
|
+
{fieldState.error?.message}
|
|
180
|
+
</FieldError>
|
|
181
|
+
)}
|
|
182
|
+
`;
|
|
183
|
+
}
|