ruact 0.0.5 → 0.0.7
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +74 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +109 -0
- data/lib/ruact.rb +20 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +70 -328
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/manifest_resolver_spec.rb +159 -0
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +15 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +58 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- 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
|
+
}
|