@betterstart/cli 0.1.35 → 0.1.37

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/dist/cli.js CHANGED
@@ -353,7 +353,7 @@ import path3 from "path";
353
353
 
354
354
  // src/utils/string.ts
355
355
  function toPascalCase(str) {
356
- return str.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
356
+ return str.replace(/([a-z])([A-Z])/g, "$1-$2").split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
357
357
  }
358
358
  function toCamelCase(str) {
359
359
  const p7 = toPascalCase(str);
@@ -448,7 +448,8 @@ function generateEmailTemplate(schema, cwd, cmsDir, options = {}) {
448
448
  const pascal = toPascalCase(formName);
449
449
  const fields = getAllFormSchemaFields(schema).filter((f) => f.name);
450
450
  const includeDynamic = hasDynamicFields(schema);
451
- const filePath = path3.join(cwd, cmsDir, "lib", "emails", `${formName}-submission.tsx`);
451
+ const kebab = toKebabCase(formName);
452
+ const filePath = path3.join(cwd, cmsDir, "lib", "emails", `${kebab}-submission.tsx`);
452
453
  const dir = path3.dirname(filePath);
453
454
  if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
454
455
  if (fs3.existsSync(filePath) && !options.force) {
@@ -815,7 +816,7 @@ function DeleteAction({ id }: { id: number }) {
815
816
  <AlertDialogFooter>
816
817
  <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
817
818
  <AlertDialogAction
818
- onClick={(e) => { e.preventDefault(); handleDelete() }}
819
+ onClick={(e: React.MouseEvent) => { e.preventDefault(); handleDelete() }}
819
820
  disabled={isPending}
820
821
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
821
822
  >
@@ -833,14 +834,14 @@ export const columns: ColumnDef<${pascal}SubmissionData>[] = [
833
834
  header: ({ table }) => (
834
835
  <Checkbox
835
836
  checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
836
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
837
+ onCheckedChange={(value: boolean) => table.toggleAllPageRowsSelected(!!value)}
837
838
  aria-label="Select all"
838
839
  />
839
840
  ),
840
841
  cell: ({ row }) => (
841
842
  <Checkbox
842
843
  checked={row.getIsSelected()}
843
- onCheckedChange={(value) => row.toggleSelected(!!value)}
844
+ onCheckedChange={(value: boolean) => row.toggleSelected(!!value)}
844
845
  aria-label="Select row"
845
846
  />
846
847
  ),
@@ -989,7 +990,7 @@ export function ${pascal}SubmissionsPageContent<TValue>({
989
990
  <AlertDialogFooter>
990
991
  <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
991
992
  <AlertDialogAction
992
- onClick={(e) => { e.preventDefault(); handleBulkDelete() }}
993
+ onClick={(e: React.MouseEvent) => { e.preventDefault(); handleBulkDelete() }}
993
994
  disabled={isPending}
994
995
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
995
996
  >
@@ -1424,6 +1425,20 @@ function generateViewPage(pascal, kebab, fields, label, includeDynamic) {
1424
1425
  </p>
1425
1426
  </div>`;
1426
1427
  }
1428
+ if (f.type === "list" || f.type === "multiselect") {
1429
+ return ` <div className="space-y-1">
1430
+ <p className="text-sm font-medium text-muted-foreground">${lbl}</p>
1431
+ <div className="text-sm">
1432
+ {Array.isArray(submission.${name}) && submission.${name}.length > 0 ? (
1433
+ <ul className="list-disc list-inside space-y-1">
1434
+ {submission.${name}.map((item: Record<string, unknown>, idx: number) => (
1435
+ <li key={idx}>{typeof item === 'string' ? item : Object.entries(item).map(([k, v]) => \`\${k}: \${v}\`).join(', ')}</li>
1436
+ ))}
1437
+ </ul>
1438
+ ) : '-'}
1439
+ </div>
1440
+ </div>`;
1441
+ }
1427
1442
  return ` <div className="space-y-1">
1428
1443
  <p className="text-sm font-medium text-muted-foreground">${lbl}</p>
1429
1444
  <p className="text-sm">{submission.${name} ?? '-'}</p>
@@ -1727,7 +1742,8 @@ function generateFormActions(schema, cwd, actionsDir, options) {
1727
1742
  return { success: false, error: '${f.label} is required' }
1728
1743
  }`
1729
1744
  ).join("\n ");
1730
- const filePath = path6.join(cwd, actionsDir, `${formName}-form.ts`);
1745
+ const kebab = toKebabCase(formName);
1746
+ const filePath = path6.join(cwd, actionsDir, `${kebab}-form.ts`);
1731
1747
  const dir = path6.dirname(filePath);
1732
1748
  if (!fs6.existsSync(dir)) fs6.mkdirSync(dir, { recursive: true });
1733
1749
  if (fs6.existsSync(filePath) && !options.force) {
@@ -1821,7 +1837,7 @@ export async function create${pascal}Submission(
1821
1837
  // Resolve notification emails from form settings or env var
1822
1838
  const settings = await getFormSettings('${formName}')
1823
1839
  const notificationEmails = settings?.notificationEmails
1824
- ? settings.notificationEmails.split(',').map((e) => e.trim()).filter(Boolean)
1840
+ ? settings.notificationEmails.split(',').map((e: string) => e.trim()).filter(Boolean)
1825
1841
  : process.env.NOTIFICATION_EMAIL
1826
1842
  ? [process.env.NOTIFICATION_EMAIL]
1827
1843
  : []
@@ -1889,7 +1905,7 @@ export async function export${pascal}SubmissionsCSV(): Promise<string> {
1889
1905
  const headers = Object.keys(submissions[0]).join(',')
1890
1906
  const rows = submissions.map((sub) =>
1891
1907
  Object.values(sub)
1892
- .map((val) => {
1908
+ .map((val: unknown) => {
1893
1909
  if (val === null || val === undefined) return ''
1894
1910
  const str = String(val)
1895
1911
  if (str.includes(',') || str.includes('"') || str.includes('\\n')) {
@@ -2128,7 +2144,7 @@ function formFieldToZodType(field, options = {}) {
2128
2144
  }
2129
2145
  return isRequired ? `z.string().min(1, '${label} is required')` : "z.string().optional()";
2130
2146
  case "checkbox":
2131
- return isRequired ? `z.literal(true, { message: '${label} is required' })` : "z.boolean().optional()";
2147
+ return isRequired ? `z.boolean().refine(val => val === true, { message: '${label} is required' })` : "z.boolean().optional()";
2132
2148
  case "multiselect":
2133
2149
  return isRequired ? `z.array(z.string()).min(1, '${label} is required')` : "z.array(z.string()).optional()";
2134
2150
  case "timezone":
@@ -2210,6 +2226,9 @@ function toTypeScriptType(field, mode = "output") {
2210
2226
  }
2211
2227
 
2212
2228
  // src/generators/form-pipeline/form-component.ts
2229
+ function escapeJsx(str) {
2230
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2231
+ }
2213
2232
  function generateFormComponent(schema, cwd, cmsDir, options) {
2214
2233
  const formName = schema.name;
2215
2234
  const pascal = toPascalCase(formName);
@@ -2226,22 +2245,31 @@ function generateFormComponent(schema, cwd, cmsDir, options) {
2226
2245
  if (f.type === "checkbox") return ` ${f.name}: false`;
2227
2246
  if (f.type === "number") return ` ${f.name}: undefined`;
2228
2247
  if (f.type === "multiselect" || f.type === "list") return ` ${f.name}: []`;
2248
+ if (f.type === "select" || f.type === "radio") return ` ${f.name}: undefined`;
2229
2249
  if (f.defaultValue !== void 0) return ` ${f.name}: '${f.defaultValue}'`;
2230
2250
  return ` ${f.name}: ''`;
2231
2251
  }).join(",\n");
2252
+ const listFields = fields.filter(
2253
+ (f) => f.name && f.type === "list" && f.fields && f.fields.length > 0
2254
+ );
2255
+ const hasListFields = listFields.length > 0;
2256
+ const fieldArrayDecls = listFields.map(
2257
+ (f) => ` const ${f.name}FieldArray = useFieldArray({ control: form.control, name: '${f.name}' })`
2258
+ ).join("\n");
2232
2259
  const fieldJSX = fields.filter((f) => f.name && !f.hidden).map((f) => generateFieldJSX(f)).join("\n\n");
2233
2260
  const submitText = schema.submitButtonText || "Submit";
2234
- const successMessage = (schema.successMessage || "Form submitted successfully!").replace(
2235
- /'/g,
2236
- "\\'"
2237
- );
2261
+ const successMessage = escapeJsx(schema.successMessage || "Form submitted successfully!");
2262
+ const rhfImport = hasListFields ? `import { useFieldArray, useForm } from 'react-hook-form'` : `import { useForm } from 'react-hook-form'`;
2263
+ const fieldArraySetup = hasListFields ? `
2264
+ ${fieldArrayDecls}
2265
+ ` : "";
2238
2266
  const content = `'use client'
2239
2267
 
2240
2268
  import { zodResolver } from '@hookform/resolvers/zod'
2241
2269
  import { useState } from 'react'
2242
- import { useForm } from 'react-hook-form'
2270
+ ${rhfImport}
2243
2271
  import { z } from 'zod/v3'
2244
- import { create${pascal}Submission } from '@cms/actions/${formName}-form'
2272
+ import { create${pascal}Submission } from '@cms/actions/${kebab}-form'
2245
2273
  import { Button } from '@cms/components/ui/button'
2246
2274
  import {
2247
2275
  Form,
@@ -2278,7 +2306,7 @@ export function ${pascal}Form() {
2278
2306
  ${defaults}
2279
2307
  },
2280
2308
  })
2281
-
2309
+ ${fieldArraySetup}
2282
2310
  async function onSubmit(values: FormValues) {
2283
2311
  setSubmitting(true)
2284
2312
  try {
@@ -2326,11 +2354,11 @@ ${fieldJSX}
2326
2354
  }
2327
2355
  function generateFieldJSX(field) {
2328
2356
  const name = field.name || "";
2329
- const label = field.label;
2357
+ const label = escapeJsx(field.label);
2330
2358
  const placeholder = field.placeholder || "";
2331
2359
  const hint = field.hint || "";
2332
2360
  const hintJSX = hint ? `
2333
- <FormDescription>${hint}</FormDescription>` : "";
2361
+ <FormDescription>${escapeJsx(hint)}</FormDescription>` : "";
2334
2362
  const requiredStar = field.required ? ' <span className="text-destructive">*</span>' : "";
2335
2363
  switch (field.type) {
2336
2364
  case "textarea":
@@ -2351,7 +2379,7 @@ function generateFieldJSX(field) {
2351
2379
  case "radio":
2352
2380
  if (field.options && field.options.length > 0) {
2353
2381
  const optionItems = field.options.map(
2354
- (opt) => ` <SelectItem value="${opt.value}">${opt.label}</SelectItem>`
2382
+ (opt) => ` <SelectItem value="${opt.value}">${escapeJsx(opt.label)}</SelectItem>`
2355
2383
  ).join("\n");
2356
2384
  return ` <FormField
2357
2385
  control={form.control}
@@ -2427,6 +2455,11 @@ ${optionItems}
2427
2455
  requiredStar,
2428
2456
  "tel"
2429
2457
  );
2458
+ case "file":
2459
+ case "upload":
2460
+ return generateTextFieldJSX(name, label, placeholder, hintJSX, requiredStar, "file");
2461
+ case "list":
2462
+ return generateListFieldJSX(field, name, label, hintJSX, requiredStar);
2430
2463
  default:
2431
2464
  return generateTextFieldJSX(name, label, placeholder, hintJSX, requiredStar, "text");
2432
2465
  }
@@ -2446,6 +2479,81 @@ function generateTextFieldJSX(name, label, placeholder, hintJSX, requiredStar, i
2446
2479
  )}
2447
2480
  />`;
2448
2481
  }
2482
+ function generateListFieldJSX(field, name, label, hintJSX, requiredStar) {
2483
+ if (!field.fields || field.fields.length === 0) {
2484
+ return generateTextFieldJSX(name, label, field.placeholder || "", hintJSX, requiredStar, "text");
2485
+ }
2486
+ const singularLabel = singularize(label);
2487
+ const nestedFieldsJSX = field.fields.map((nf) => {
2488
+ const nfLabel = escapeJsx(nf.label || nf.name || "");
2489
+ const nfPlaceholder = nf.placeholder || "";
2490
+ if (nf.type === "select" && nf.options && nf.options.length > 0) {
2491
+ const selectItems = nf.options.map(
2492
+ (opt) => ` <SelectItem value="${opt.value}">${escapeJsx(opt.label)}</SelectItem>`
2493
+ ).join("\n");
2494
+ return ` <FormField
2495
+ control={form.control}
2496
+ name={\`${name}.\${index}.${nf.name}\`}
2497
+ render={({ field: formField }) => (
2498
+ <FormItem className="flex-1">
2499
+ <FormLabel>${nfLabel}</FormLabel>
2500
+ <Select onValueChange={formField.onChange} defaultValue={formField.value}>
2501
+ <FormControl>
2502
+ <SelectTrigger>
2503
+ <SelectValue placeholder="${nf.placeholder || "Select..."}" />
2504
+ </SelectTrigger>
2505
+ </FormControl>
2506
+ <SelectContent>
2507
+ ${selectItems}
2508
+ </SelectContent>
2509
+ </Select>
2510
+ <FormMessage />
2511
+ </FormItem>
2512
+ )}
2513
+ />`;
2514
+ }
2515
+ return ` <FormField
2516
+ control={form.control}
2517
+ name={\`${name}.\${index}.${nf.name}\`}
2518
+ render={({ field: formField }) => (
2519
+ <FormItem className="flex-1">
2520
+ <FormLabel>${nfLabel}</FormLabel>
2521
+ <FormControl>
2522
+ <Input type="text" placeholder="${nfPlaceholder}" {...formField} />
2523
+ </FormControl>
2524
+ <FormMessage />
2525
+ </FormItem>
2526
+ )}
2527
+ />`;
2528
+ }).join("\n");
2529
+ const defaultObj = field.fields.map((nf) => {
2530
+ if (nf.type === "select" || nf.type === "radio") return `${nf.name}: undefined`;
2531
+ if (nf.type === "checkbox") return `${nf.name}: false`;
2532
+ if (nf.type === "number") return `${nf.name}: undefined`;
2533
+ return `${nf.name}: ''`;
2534
+ }).join(", ");
2535
+ return ` <div className="space-y-4">
2536
+ <FormLabel>${label}${requiredStar}</FormLabel>${hintJSX}
2537
+ {${name}FieldArray.fields.map((item, index) => (
2538
+ <div key={item.id} className="flex items-end gap-2 rounded-lg border p-4">
2539
+ <div className="flex-1 space-y-4">
2540
+ ${nestedFieldsJSX}
2541
+ </div>
2542
+ <Button type="button" variant="ghost" size="sm" onClick={() => ${name}FieldArray.remove(index)}>
2543
+ Remove
2544
+ </Button>
2545
+ </div>
2546
+ ))}
2547
+ <Button
2548
+ type="button"
2549
+ variant="outline"
2550
+ size="sm"
2551
+ onClick={() => ${name}FieldArray.append({ ${defaultObj} })}
2552
+ >
2553
+ Add ${singularLabel}
2554
+ </Button>
2555
+ </div>`;
2556
+ }
2449
2557
 
2450
2558
  // src/generators/form-pipeline/form-database.ts
2451
2559
  import fs8 from "fs";
@@ -2514,7 +2622,8 @@ function generateFormHook(schema, cwd, hooksDir, options) {
2514
2622
  const formName = schema.name;
2515
2623
  const pascal = toPascalCase(formName);
2516
2624
  const camel = toCamelCase(formName);
2517
- const filePath = path9.join(cwd, hooksDir, `use-${toKebabCase(formName)}-form.ts`);
2625
+ const kebab = toKebabCase(formName);
2626
+ const filePath = path9.join(cwd, hooksDir, `use-${kebab}-form.ts`);
2518
2627
  const dir = path9.dirname(filePath);
2519
2628
  if (!fs9.existsSync(dir)) fs9.mkdirSync(dir, { recursive: true });
2520
2629
  if (fs9.existsSync(filePath) && !options.force) {
@@ -2528,8 +2637,8 @@ function generateFormHook(schema, cwd, hooksDir, options) {
2528
2637
  export${pascal}SubmissionsJSON,
2529
2638
  get${pascal}Submission,
2530
2639
  get${pascal}Submissions,
2531
- } from '@cms/actions/${formName}-form'
2532
- import type { Create${pascal}SubmissionInput } from '@cms/actions/${formName}-form'
2640
+ } from '@cms/actions/${kebab}-form'
2641
+ import type { Create${pascal}SubmissionInput } from '@cms/actions/${kebab}-form'
2533
2642
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2534
2643
  import { toast } from 'sonner'
2535
2644