ruact 0.0.4 → 0.0.6

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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "rails/generators/named_base"
6
+
7
+ module Ruact
8
+ module Generators
9
+ class ScaffoldGenerator < Rails::Generators::NamedBase
10
+ # Story 10.5 — the shadcn/ui dependency pre-flight's read-only helpers:
11
+ # the authoritative add-list (derived from the templates' own import
12
+ # predicates), state detection (complete / missing / partial), the
13
+ # copy-pasteable guidance messages, and the best-effort version-compat
14
+ # read + warning. Extracted into its own module (mirrors the 10.3
15
+ # {FormHelpers} / 10.2 {ScaffoldAttribute} extractions) to keep
16
+ # {ScaffoldGenerator} within its class-length budget. Mixed in below the
17
+ # `no_tasks` boundary so these never register as Thor commands — only the
18
+ # public `check_shadcn_setup` task (in the generator) is a command.
19
+ module ShadcnPreflight
20
+ # Documentation anchor for the shadcn dependency pre-flight: how to set
21
+ # up shadcn, and how to override the version-compat warning.
22
+ SHADCN_DOCS_POINTER =
23
+ "https://github.com/luizcg/ruact/blob/main/website/docs/api/scaffold.md#shadcnui-setup"
24
+
25
+ # The pre-flight body (the {ScaffoldGenerator#check_shadcn_setup} Thor
26
+ # command delegates here). Detect the host's shadcn state, surface the
27
+ # version-compat warning, and ABORT before any write (via `raise
28
+ # Thor::Error`, like `assert_supported_attribute_types!`) when the setup
29
+ # is missing/partial — unless `--skip-shadcn-check`.
30
+ def run_shadcn_preflight!
31
+ warn_incompatible_shadcn_version
32
+
33
+ state = shadcn_setup_state
34
+ return if state == :complete
35
+ # --skip-shadcn-check writes anyway; detection still ran so the
36
+ # templates can emit the in-file banner (see {#shadcn_missing?}).
37
+ return if skip_shadcn_check?
38
+
39
+ raise Thor::Error, state == :missing ? missing_shadcn_message : partial_shadcn_message
40
+ end
41
+
42
+ # AC1/AC2/AC4/AC7 — the authoritative `@/components/ui/*` primitives the
43
+ # generated output imports, DERIVED from the templates' own predicates
44
+ # (the {FormHelpers} `form_uses_*?`) so it can never drift from the
45
+ # emitted import set. Ordered to match the documented single `add` line:
46
+ # `button` + the form-conditional input-family, then the always-present
47
+ # List/DeleteDialog primitives. EVERY entry is a plain `npx shadcn add`
48
+ # primitive — there is NO `data-table` recipe and NO `@tanstack/react-table`
49
+ # dependency (Story 10.2b removed the engine; the List is a plain `table`).
50
+ def required_shadcn_components
51
+ components = ["button"]
52
+ components << "input" if form_uses_input?
53
+ components << "textarea" if form_uses_textarea?
54
+ components << "switch" if form_uses_switch?
55
+ components << "select" if form_uses_select?
56
+ components.push("label", "badge", "table", "alert-dialog", "dropdown-menu")
57
+ end
58
+
59
+ # The `app/javascript/components/ui/` directory the `@/components/ui/*`
60
+ # alias maps to (consistent with `ruact:install`'s `app/javascript`
61
+ # convention), and shadcn's `components.json` config marker at app root.
62
+ def shadcn_ui_dir
63
+ Pathname(destination_root).join("app/javascript/components/ui")
64
+ end
65
+
66
+ def shadcn_config_path
67
+ Pathname(destination_root).join("components.json")
68
+ end
69
+
70
+ def shadcn_component_file(name)
71
+ shadcn_ui_dir.join("#{name}.tsx")
72
+ end
73
+
74
+ # The required primitives whose `ui/<name>.tsx` file is absent (drives the
75
+ # partial-setup "lists exactly which components are missing" guidance).
76
+ def missing_shadcn_components
77
+ required_shadcn_components.reject { |name| shadcn_component_file(name).exist? }
78
+ end
79
+
80
+ # Detection (memoized — the FS does not change during a run):
81
+ # :complete = components.json present AND every required ui/<name>.tsx present
82
+ # :missing = no components.json (shadcn is not initialized) → guide to
83
+ # `init` + the full `add`. A `components.json` is the
84
+ # authoritative "shadcn is set up" marker; without it the
85
+ # setup is incomplete even if a `components/ui/` dir exists
86
+ # (so we never emit a bare/empty `add` for a config-less app).
87
+ # :partial = components.json present but some required primitive absent
88
+ # → targeted `add` of exactly the missing pieces.
89
+ def shadcn_setup_state
90
+ @shadcn_setup_state ||= compute_shadcn_setup_state
91
+ end
92
+
93
+ def compute_shadcn_setup_state
94
+ return :missing unless shadcn_config_path.exist?
95
+ return :complete if missing_shadcn_components.empty?
96
+
97
+ :partial
98
+ end
99
+
100
+ def skip_shadcn_check?
101
+ options[:skip_shadcn_check]
102
+ end
103
+
104
+ # AC3 — true only when the developer bypassed the pre-flight with
105
+ # `--skip-shadcn-check` AND the setup is still incomplete, so the
106
+ # components emit the prominent in-file banner. Default/configured runs
107
+ # leave this false → the templates' default-path bytes are unchanged (the
108
+ # 10.1–10.4/10.2b `tsc` byte-equality fixtures stay valid without regen).
109
+ def shadcn_missing?
110
+ skip_shadcn_check? && shadcn_setup_state != :complete
111
+ end
112
+
113
+ # The single copy-pasteable `add` line for the given component list.
114
+ def shadcn_add_command(components)
115
+ "npx shadcn@latest add #{components.join(' ')}"
116
+ end
117
+
118
+ # AC3 — the `add` command for the in-file banner. Uses the detectably
119
+ # missing primitives, falling back to the full required list when none is
120
+ # missing (e.g. the `ui/*` files exist but `components.json` is absent →
121
+ # state `:missing`) so the banner NEVER prints a bare `npx shadcn add`.
122
+ def shadcn_banner_add_command
123
+ components = missing_shadcn_components
124
+ components = required_shadcn_components if components.empty?
125
+ shadcn_add_command(components)
126
+ end
127
+
128
+ # AC2 — missing setup: the full `init` + single `add <full list>` sequence
129
+ # (all plain `add`s; NO data-table recipe, NO @tanstack install line).
130
+ def missing_shadcn_message
131
+ <<~MSG.chomp
132
+ ruact:scaffold — shadcn/ui is not set up in this app yet.
133
+ The generated components import from @/components/ui/*, which does not exist.
134
+ Set up shadcn/ui first, then re-run this generator:
135
+
136
+ npx shadcn@latest init
137
+ #{shadcn_add_command(required_shadcn_components)}
138
+
139
+ No files were written (no partial state). Advanced: pass --skip-shadcn-check
140
+ to generate anyway and manage @/components/ui/* yourself.
141
+ MSG
142
+ end
143
+
144
+ # AC4 — partial setup: the EXACT missing names + the targeted `add` (only
145
+ # the missing pieces). Never auto-run.
146
+ def partial_shadcn_message
147
+ missing = missing_shadcn_components
148
+ <<~MSG.chomp
149
+ ruact:scaffold — shadcn/ui is configured, but components the scaffold imports
150
+ are not installed yet:
151
+ #{missing.join(', ')}
152
+ Add them, then re-run this generator:
153
+
154
+ #{shadcn_add_command(missing)}
155
+
156
+ No files were written (no partial state). Advanced: pass --skip-shadcn-check
157
+ to generate anyway and manage @/components/ui/* yourself.
158
+ MSG
159
+ end
160
+
161
+ # AC6 — the host `package.json`, parsed best-effort (nil when absent or
162
+ # unparseable; a malformed file must not crash the generator).
163
+ def host_package_json
164
+ return @host_package_json if defined?(@host_package_json)
165
+
166
+ path = Pathname(destination_root).join("package.json")
167
+ @host_package_json = path.exist? ? safe_parse_json(path.read) : nil
168
+ end
169
+
170
+ def safe_parse_json(raw)
171
+ JSON.parse(raw)
172
+ rescue JSON::ParserError
173
+ nil
174
+ end
175
+
176
+ # AC6 — best-effort installed shadcn version string: `shadcn` (current)
177
+ # or legacy `shadcn-ui`, from dependencies/devDependencies. nil when run
178
+ # via `npx` (no pin) → a soft note, not a warning.
179
+ def shadcn_version_string
180
+ pkg = host_package_json
181
+ return nil unless pkg.is_a?(Hash)
182
+
183
+ %w[dependencies devDependencies].each do |section|
184
+ deps = pkg[section]
185
+ next unless deps.is_a?(Hash)
186
+
187
+ raw = deps["shadcn"] || deps["shadcn-ui"]
188
+ return raw if raw
189
+ end
190
+ nil
191
+ end
192
+
193
+ # The MAJOR of the installed version (`"^2.1.0"` → 2, `">=1.0"` → 1).
194
+ def installed_shadcn_major
195
+ raw = shadcn_version_string
196
+ return nil unless raw
197
+
198
+ digits = raw.to_s.gsub(/[^0-9.]/, "").split(".").first
199
+ return nil if digits.nil? || digits.empty?
200
+
201
+ digits.to_i
202
+ end
203
+
204
+ # AC6 — warn (never abort) when the installed shadcn major is outside the
205
+ # tested set; a soft note when the version can't be determined.
206
+ def warn_incompatible_shadcn_version
207
+ major = installed_shadcn_major
208
+ if major.nil?
209
+ say_status "note",
210
+ "could not determine the installed shadcn version (run via npx?); " \
211
+ "skipping the version-compat check", :yellow
212
+ return
213
+ end
214
+
215
+ compatible = Ruact.config.shadcn_compatible_versions
216
+ return if compatible.include?(major)
217
+
218
+ say_status "warning",
219
+ "shadcn v#{major} is not regression-tested with this ruact " \
220
+ "(tested majors: #{compatible.join(', ')}); the generated code may import " \
221
+ "from outdated @/components/ui/* paths. Align shadcn to a tested major, or add " \
222
+ "#{major} via Ruact.configure { |c| c.shadcn_compatible_versions = " \
223
+ "#{(compatible + [major]).sort.inspect} } in config/initializers/ruact.rb once " \
224
+ "verified. See #{SHADCN_DOCS_POINTER}", :yellow
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ <% unless typescript? -%>
4
+ // Generated with --javascript: this .jsx file forfeits the FR99 typed server
5
+ // boundary and the FR100 call-site contract (both TS-only). Re-run without
6
+ // --javascript for the typed .tsx variant.
7
+ <% end -%>
8
+ <% if shadcn_missing? -%>
9
+ // ⚠️ GENERATED WITH --skip-shadcn-check — shadcn/ui is not fully set up.
10
+ // The @/components/ui/* imports below will NOT resolve until you install them
11
+ // (the Vite dev server fails the build with an import error — loud by design):
12
+ // <%= shadcn_banner_add_command %>
13
+ // (If you have not set up shadcn at all, run `npx shadcn@latest init` first.)
14
+ <% end -%>
15
+ // <%= class_name %> delete confirmation — a CONTROLLED shadcn AlertDialog. The
16
+ // PARENT owns `open` and provides `onConfirm`; this component owns only its local
17
+ // submit/error state. `onConfirm` performs the destroy<%= class_name %> call (DELETE
18
+ // /<%= plural_name %>/:id) and reports `{ ok, error? }`:
19
+ //
20
+ // - on success the parent closes the dialog (in-list removal of the row, or the
21
+ // server-driven `$redirect` the runtime follows) — no URL is built here;
22
+ // - on failure the dialog STAYS OPEN and shows the message inline (the gem's
23
+ // structured-error shape: the exception message in development, the generic
24
+ // server message in production).
25
+ //
26
+ // There is no Rails `method=delete` fallback — the destroy<%= class_name %> action
27
+ // is the only path. Cancel / Escape / outside-click close via `onOpenChange(false)`
28
+ // without deleting; focus returns to the row's Delete trigger (Radix default —
29
+ // the trigger is never unmounted while the dialog animates).
30
+ import { useState } from "react";
31
+ import {
32
+ AlertDialog,
33
+ AlertDialogAction,
34
+ AlertDialogCancel,
35
+ AlertDialogContent,
36
+ AlertDialogDescription,
37
+ AlertDialogFooter,
38
+ AlertDialogHeader,
39
+ AlertDialogTitle,
40
+ } from "@/components/ui/alert-dialog";
41
+ <% if typescript? -%>
42
+
43
+ type <%= class_name %>Row = { <%= ts_row_fields %> };
44
+
45
+ // FR100 — opt-in call-site contract. INERT for this component: it is rendered
46
+ // only from <%= class_name %>List.tsx (JSX), never from an ERB `<Component>` tag,
47
+ // so the 13.5 preprocess validator never runs against it. Retained for uniformity
48
+ // with the List/Form.
49
+ export const __ruactContract = {
50
+ props: { <%= singular_name %>: "required" },
51
+ };
52
+ <% end -%>
53
+
54
+ export function <%= class_name %>DeleteDialog({
55
+ open,
56
+ onOpenChange,
57
+ <%= singular_name %>,
58
+ onConfirm,
59
+ }<% if typescript? %>: {
60
+ open: boolean;
61
+ onOpenChange: (open: boolean) => void;
62
+ <%= singular_name %>: <%= class_name %>Row;
63
+ onConfirm: () => Promise<{ ok: boolean; error?: string }>;
64
+ }<% end %>) {
65
+ const [submitting, setSubmitting] = useState(false);
66
+ const [error, setError] = useState<% if typescript? %><string | null><% end %>(null);
67
+
68
+ async function handleConfirm(event<% if typescript? %>: { preventDefault: () => void }<% end %>) {
69
+ // Keep the dialog open across the async destroy — Radix's AlertDialogAction
70
+ // would otherwise close it on click. The PARENT closes it (via onOpenChange)
71
+ // only after a successful delete; a failure leaves it open with the message.
72
+ event.preventDefault();
73
+ setError(null);
74
+ setSubmitting(true);
75
+ const res = await onConfirm();
76
+ setSubmitting(false);
77
+ if (!res.ok) {
78
+ setError(res.error ?? "Could not delete this <%= human_name.downcase %>");
79
+ }
80
+ }
81
+
82
+ return (
83
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
84
+ <AlertDialogContent>
85
+ <AlertDialogHeader>
86
+ <AlertDialogTitle>Delete “{String(<%= singular_name %>.<%= display_attribute %> ?? "")}”?</AlertDialogTitle>
87
+ <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
88
+ </AlertDialogHeader>
89
+ {error && <p className="text-sm text-destructive">{error}</p>}
90
+ <AlertDialogFooter>
91
+ {/* Cancel gets default focus (Radix) — the safe choice is the default. */}
92
+ <AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
93
+ <AlertDialogAction
94
+ onClick={handleConfirm}
95
+ disabled={submitting}
96
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
97
+ >
98
+ {submitting ? "Deleting…" : "Delete"}
99
+ </AlertDialogAction>
100
+ </AlertDialogFooter>
101
+ </AlertDialogContent>
102
+ </AlertDialog>
103
+ );
104
+ }
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ <% unless typescript? -%>
4
+ // Generated with --javascript: this .jsx file forfeits the FR99 typed server
5
+ // boundary and the FR100 call-site contract (both TS-only). Re-run without
6
+ // --javascript for the typed .tsx variant.
7
+ <% end -%>
8
+ <% if shadcn_missing? -%>
9
+ // ⚠️ GENERATED WITH --skip-shadcn-check — shadcn/ui is not fully set up.
10
+ // The @/components/ui/* imports below will NOT resolve until you install them
11
+ // (the Vite dev server fails the build with an import error — loud by design):
12
+ // <%= shadcn_banner_add_command %>
13
+ // (If you have not set up shadcn at all, run `npx shadcn@latest init` first.)
14
+ <% end -%>
15
+ // <%= class_name %> form — shared by `new` (initial == null) and `edit` (initial
16
+ // == the serialized record). Each field renders the shadcn control mapped to its
17
+ // attribute type (Input / Textarea / Switch / Select), labelled, with the FR98
18
+ // server error surfaced inline beneath it. Submits through the v2 action
19
+ // accessors: create<%= class_name %> (POST /<%= plural_name %>) or update<%= class_name %> (PATCH /<%= plural_name %>/:id). On
20
+ // success the controller drives navigation SERVER-SIDE via `redirect_to`
21
+ // (the `$redirect` the runtime follows) — there is no client URL building here.
22
+ // On a validation failure the SAME response carries the FR98 attribute-keyed
23
+ // `errors` map, surfaced inline per field (the client stays on the form).
24
+ //
25
+ // The `@/components/ui/*` primitives below are installed by Story 10.5; a freshly
26
+ // scaffolded app will not resolve them until then (the live demo is Story 10.7).
27
+ //
28
+ // Client-side validation is intentionally NOT here — it is server-only with a
29
+ // full round-trip (Story 10.6 adds the model-validation bridge). The controlled
30
+ // `useState` keeps this dependency-free; to adopt rich client validation later,
31
+ // swap the controlled state for react-hook-form's `useForm` + shadcn `<Form>` and
32
+ // feed these same FR98 `errors` into `setError` per key (opt-in — not the default).
33
+ import { useState<% if typescript? %>, type FormEvent<% end %> } from "react";
34
+ import { create<%= class_name %>, update<%= class_name %> } from "@/.ruact/server-functions";
35
+ import { Button } from "@/components/ui/button";
36
+ import { Label } from "@/components/ui/label";
37
+ <% if form_uses_input? -%>
38
+ import { Input } from "@/components/ui/input";
39
+ <% end -%>
40
+ <% if form_uses_textarea? -%>
41
+ import { Textarea } from "@/components/ui/textarea";
42
+ <% end -%>
43
+ <% if form_uses_switch? -%>
44
+ import { Switch } from "@/components/ui/switch";
45
+ <% end -%>
46
+ <% if form_uses_select? -%>
47
+ import {
48
+ Select,
49
+ SelectContent,
50
+ SelectItem,
51
+ SelectTrigger,
52
+ SelectValue,
53
+ } from "@/components/ui/select";
54
+ <% end -%>
55
+ <% if typescript? -%>
56
+
57
+ type <%= class_name %>Row = { <%= ts_row_fields %> };
58
+
59
+ // FR100 — opt-in call-site contract: `initial` is OPTIONAL (`new` omits it,
60
+ // `edit` passes the serialized record); each `references` field adds an
61
+ // optional `<name>Options` prop the new/edit views pass. A typo'd prop name
62
+ // fails at preprocess time instead of silently arriving as `undefined`.
63
+ export const __ruactContract = {
64
+ props: <%= form_contract_props %>,
65
+ };
66
+ <% end -%>
67
+
68
+ export function <%= class_name %>Form({ <%= form_props_params.join(", ") %> }<% if typescript? %>: <%= form_props_type %><% end %>) {
69
+ <% scaffold_attributes.each do |attr| -%>
70
+ <% if attr.boolean? -%>
71
+ const [<%= attr.js_var %>, <%= attr.js_setter %>] = useState(Boolean(initial?.<%= attr.column_name %> ?? false));
72
+ <% else -%>
73
+ // String-valued state: native/shadcn controls bind `string` (numbers + refs
74
+ // arrive on the row as numbers, so coerce here). The controller re-coerces.
75
+ const [<%= attr.js_var %>, <%= attr.js_setter %>] = useState(String(initial?.<%= attr.column_name %> ?? ""));
76
+ <% end -%>
77
+ <% end -%>
78
+ // FR98 — keyed { [attr]: string[] }; `{}` means "no errors" (always present).
79
+ const [errors, setErrors] = useState<% if typescript? %><Record<string, string[]>><% end %>({});
80
+ const [saving, setSaving] = useState(false);
81
+
82
+ async function handleSubmit(event<% if typescript? %>: FormEvent<% end %>) {
83
+ event.preventDefault();
84
+ setSaving(true);
85
+ setErrors({});
86
+
87
+ const payload = {
88
+ <% scaffold_attributes.each do |attr| -%>
89
+ <%= attr.column_name %>: <%= attr.js_var %>,
90
+ <% end -%>
91
+ };
92
+
93
+ // On success the server returns a `$redirect` and the runtime navigates
94
+ // away (the code below does not run); on a validation failure we get the
95
+ // keyed `errors` map back instead.
96
+ const result = (initial != null
97
+ ? await update<%= class_name %>({ id: initial.id, ...payload })
98
+ : await create<%= class_name %>(payload))<% if typescript? %> as { errors?: Record<string, string[]> } | null<% end %>;
99
+
100
+ setSaving(false);
101
+ if (result && result.errors) {
102
+ setErrors(result.errors);
103
+ }
104
+ }
105
+
106
+ const errorsFor = (attr<% if typescript? %>: string<% end %>) => errors[attr] ?? [];
107
+
108
+ return (
109
+ <form onSubmit={handleSubmit} className="mx-auto max-w-xl space-y-6 px-4 py-8">
110
+ <h1 className="text-2xl font-semibold tracking-tight">
111
+ {initial != null ? `Edit <%= human_name %> #${initial.id}` : "New <%= human_name %>"}
112
+ </h1>
113
+
114
+ {errorsFor("base").length > 0 && (
115
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
116
+ <ul className="list-disc space-y-1 pl-4">
117
+ {errorsFor("base").map((message, i) => (
118
+ <li key={i}>{message}</li>
119
+ ))}
120
+ </ul>
121
+ </div>
122
+ )}
123
+ <% scaffold_attributes.each do |attr| -%>
124
+ <% case attr.shadcn_control -%>
125
+ <% when :switch -%>
126
+
127
+ <div className="space-y-2">
128
+ <div className="flex items-center gap-2">
129
+ <Switch
130
+ id="<%= attr.column_name %>"
131
+ checked={<%= attr.js_var %>}
132
+ onCheckedChange={<%= attr.js_setter %>}
133
+ disabled={saving}
134
+ />
135
+ <Label htmlFor="<%= attr.column_name %>"><%= attr.column_name.humanize %></Label>
136
+ </div>
137
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
138
+ <p key={i} className="text-sm text-destructive">{message}</p>
139
+ ))}
140
+ </div>
141
+ <% when :textarea -%>
142
+
143
+ <div className="space-y-2">
144
+ <Label htmlFor="<%= attr.column_name %>"><%= attr.column_name.humanize %></Label>
145
+ <Textarea
146
+ id="<%= attr.column_name %>"
147
+ value={<%= attr.js_var %>}
148
+ onChange={(e) => <%= attr.js_setter %>(e.target.value)}
149
+ disabled={saving}
150
+ />
151
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
152
+ <p key={i} className="text-sm text-destructive">{message}</p>
153
+ ))}
154
+ </div>
155
+ <% when :select -%>
156
+
157
+ <div className="space-y-2">
158
+ <Label htmlFor="<%= attr.column_name %>"><%= attr.name.humanize %></Label>
159
+ {/* Eager <Select> of controller-provided options (ivar → `<%= attr.options_prop %>` prop),
160
+ capped at the generator's REFERENCE_OPTIONS_LIMIT. For a parent set
161
+ larger than that, swap this for a server-search combobox backed by a
162
+ parent-options read query (opt-in follow-up — that query is out of this
163
+ scaffold's scope since the parent model isn't scaffolded here). */}
164
+ <Select
165
+ value={<%= attr.js_var %>}
166
+ onValueChange={<%= attr.js_setter %>}
167
+ disabled={saving}
168
+ >
169
+ <SelectTrigger id="<%= attr.column_name %>">
170
+ <SelectValue placeholder="Select <%= attr.name.humanize.downcase %>" />
171
+ </SelectTrigger>
172
+ <SelectContent>
173
+ {<%= attr.options_prop %>.map((option) => (
174
+ <SelectItem key={option.id} value={String(option.id)}>
175
+ {option.label}
176
+ </SelectItem>
177
+ ))}
178
+ </SelectContent>
179
+ </Select>
180
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
181
+ <p key={i} className="text-sm text-destructive">{message}</p>
182
+ ))}
183
+ </div>
184
+ <% else -%>
185
+
186
+ <div className="space-y-2">
187
+ <Label htmlFor="<%= attr.column_name %>"><%= attr.column_name.humanize %></Label>
188
+ <Input
189
+ id="<%= attr.column_name %>"
190
+ type="<%= html_input_type(attr) %>"
191
+ value={<%= attr.js_var %>}
192
+ onChange={(e) => <%= attr.js_setter %>(e.target.value)}
193
+ disabled={saving}
194
+ />
195
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
196
+ <p key={i} className="text-sm text-destructive">{message}</p>
197
+ ))}
198
+ </div>
199
+ <% end -%>
200
+ <% end -%>
201
+
202
+ <div className="flex items-center gap-3 pt-2">
203
+ <Button type="submit" disabled={saving}>
204
+ {saving ? "Saving…" : initial != null ? "Update <%= human_name.downcase %>" : "Create <%= human_name.downcase %>"}
205
+ </Button>
206
+ <Button variant="outline" asChild>
207
+ <a href={initial != null ? `/<%= plural_name %>/${initial.id}` : "/<%= plural_name %>"}>Cancel</a>
208
+ </Button>
209
+ </div>
210
+ </form>
211
+ );
212
+ }