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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- 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 +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -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/railtie.rb +56 -200
- 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 +102 -0
- data/lib/ruact.rb +19 -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 +63 -326
- 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/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 +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -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 +55 -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,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Generators
|
|
5
|
+
class ScaffoldGenerator < Rails::Generators::NamedBase
|
|
6
|
+
# Story 10.3 — the shadcn Form's view-model helpers: which `@/components/ui/*`
|
|
7
|
+
# primitives to import, the component's prop signature, and the `references`
|
|
8
|
+
# → `<Select>` options sourcing (controller ivar → prop). Extracted into its
|
|
9
|
+
# own module to keep {ScaffoldGenerator} within its class-length budget
|
|
10
|
+
# (mirrors the 10.2 {ScaffoldAttribute} extraction). Mixed in below the
|
|
11
|
+
# `no_tasks` boundary so these never register as Thor commands.
|
|
12
|
+
module FormHelpers
|
|
13
|
+
# The :input-family shadcn controls (all render an `<Input>`, varying only
|
|
14
|
+
# by `type=`); extracted so the predicate's array literal isn't rebuilt on
|
|
15
|
+
# every loop iteration.
|
|
16
|
+
INPUT_SHADCN_CONTROLS = %i[input number date datetime].freeze
|
|
17
|
+
|
|
18
|
+
# The `references` attributes — each renders a shadcn `<Select>` whose
|
|
19
|
+
# options the controller loads (ivar → prop). Empty for a reference-free
|
|
20
|
+
# model (the whole options path then stays inert).
|
|
21
|
+
def reference_attributes
|
|
22
|
+
scaffold_attributes.select(&:reference?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The eager-options cap (template-friendly accessor for the constant).
|
|
26
|
+
def reference_options_limit
|
|
27
|
+
ScaffoldGenerator::REFERENCE_OPTIONS_LIMIT
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Story 10.3 (AC6) — the controller expression loading a `references`
|
|
31
|
+
# field's options as plain `{ "id" =>, "label" => }` row hashes (no
|
|
32
|
+
# `as_json`; the view passes them straight through as a prop). Labels fall
|
|
33
|
+
# back name → title → `to_s`. Capped at the threshold + 1 so the eager
|
|
34
|
+
# `<Select>` never balloons (a > threshold parent set wants the documented
|
|
35
|
+
# server-search combobox instead).
|
|
36
|
+
def reference_options_expr(ref)
|
|
37
|
+
label = "record.try(:name) || record.try(:title) || record.to_s"
|
|
38
|
+
"#{ref.reference_class_name}.limit(#{reference_options_limit + 1})" \
|
|
39
|
+
".map { |record| { \"id\" => record.id, \"label\" => #{label} } }"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- shadcn Form import predicates ------------------------------------
|
|
43
|
+
# Emit only the `@/components/ui/*` imports the model's controls use, so a
|
|
44
|
+
# reference-free Form never imports `Select`, a textarea-free Form never
|
|
45
|
+
# imports `Textarea`, etc.
|
|
46
|
+
|
|
47
|
+
def form_uses_input?
|
|
48
|
+
scaffold_attributes.any? { |attr| INPUT_SHADCN_CONTROLS.include?(attr.shadcn_control) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def form_uses_textarea?
|
|
52
|
+
scaffold_attributes.any? { |attr| attr.shadcn_control == :textarea }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def form_uses_switch?
|
|
56
|
+
scaffold_attributes.any? { |attr| attr.shadcn_control == :switch }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def form_uses_select?
|
|
60
|
+
reference_attributes.any?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The Form component's destructured props — `initial` plus one options
|
|
64
|
+
# prop per `references` field (`authorOptions = []`).
|
|
65
|
+
def form_props_params
|
|
66
|
+
["initial = null"] + reference_attributes.map { |ref| "#{ref.options_prop} = []" }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# FR99 — the Form component's prop type (typed mode only).
|
|
70
|
+
def form_props_type
|
|
71
|
+
parts = ["initial?: #{class_name}Row | null"]
|
|
72
|
+
parts += reference_attributes.map { |ref| "#{ref.options_prop}?: { id: number; label: string }[]" }
|
|
73
|
+
"{ #{parts.join('; ')} }"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# FR100 — the `__ruactContract` props object. EVERY prop the view may pass
|
|
77
|
+
# must be declared or the 13.5 call-site validator rejects it: `initial`
|
|
78
|
+
# plus each `references` options prop the new/edit views hand the Form.
|
|
79
|
+
# All optional (`new` omits `initial`; a reference-free Form omits options).
|
|
80
|
+
def form_contract_props
|
|
81
|
+
parts = ['initial: "optional"']
|
|
82
|
+
parts += reference_attributes.map { |ref| %(#{ref.options_prop}: "optional") }
|
|
83
|
+
"{ #{parts.join(', ')} }"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The `<Input type>` for the :input-family controls (string→text,
|
|
87
|
+
# number, date, datetime→datetime-local). The shadcn `<Input>` forwards
|
|
88
|
+
# `type` to the native element, so the wire formats match for free.
|
|
89
|
+
def html_input_type(attr)
|
|
90
|
+
case attr.shadcn_control
|
|
91
|
+
when :number then "number"
|
|
92
|
+
when :date then "date"
|
|
93
|
+
when :datetime then "datetime-local"
|
|
94
|
+
else "text"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Story 14.4 (FR103) — the native `<input type>` for the agnostic Form's
|
|
99
|
+
# input-family controls, mapped off the agnostic `ScaffoldAttribute#control`
|
|
100
|
+
# (TYPE_MAP) rather than the shadcn `shadcn_control`. boolean (`:checkbox`)
|
|
101
|
+
# and `references` (a `<select>`) are dispatched separately in the agnostic
|
|
102
|
+
# template, so this only resolves the text/number/date/datetime inputs.
|
|
103
|
+
def native_input_type(attr)
|
|
104
|
+
case attr.control
|
|
105
|
+
when :number then "number"
|
|
106
|
+
when :date then "date"
|
|
107
|
+
when :datetime then "datetime-local"
|
|
108
|
+
else "text"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "rails/generators"
|
|
6
|
+
require "rails/generators/named_base"
|
|
7
|
+
require "ruact"
|
|
8
|
+
require_relative "scaffold_attribute"
|
|
9
|
+
require_relative "scaffold_form_helpers"
|
|
10
|
+
require_relative "scaffold_shadcn_preflight"
|
|
11
|
+
|
|
12
|
+
module Ruact
|
|
13
|
+
module Generators
|
|
14
|
+
# Generates a complete CRUD skeleton on the v2 route-driven contract:
|
|
15
|
+
#
|
|
16
|
+
# rails generate ruact:scaffold Post title:string body:text published:boolean
|
|
17
|
+
#
|
|
18
|
+
# Story 14.3 (FR102) — the scaffold is now self-contained with Rails parity:
|
|
19
|
+
# it first DELEGATES to Rails' own public `resource` generator (model +
|
|
20
|
+
# migration + the `resources` route + host-framework test stubs, honoring
|
|
21
|
+
# `config.generators test_framework`), then OVERLAYS the ruact layer on top.
|
|
22
|
+
# No manual `rails generate model …` step is required anymore.
|
|
23
|
+
#
|
|
24
|
+
# Produces (Story 10.1 — the *skeleton*; Stories 10.2/10.3/10.4 upgrade the
|
|
25
|
+
# components to shadcn DataTable/Form/AlertDialog):
|
|
26
|
+
#
|
|
27
|
+
# 0. app/models/post.rb + db/migrate/*_create_posts.rb + host-framework
|
|
28
|
+
# model/controller test stubs — generated by the delegated Rails
|
|
29
|
+
# `resource` generator (14.3), which also draws `resources :posts`.
|
|
30
|
+
# 1. app/controllers/posts_controller.rb — seven RESTful actions, the v2
|
|
31
|
+
# shape (`include Ruact::Server`, implicit `default_render` on GET,
|
|
32
|
+
# Bucket-2 JSON on non-GET, server-driven `redirect_to`, FR98
|
|
33
|
+
# `ruact_errors`, raw `:id` finders with commented FR96 opt-in). The
|
|
34
|
+
# overlay `force`-overwrites `resource`'s bare controller (14.3 D2).
|
|
35
|
+
# 2. config/routes.rb — `resources :posts` injected.
|
|
36
|
+
# 3. app/javascript/components/PostList.tsx, PostForm.tsx,
|
|
37
|
+
# PostDeleteDialog.tsx — plain working React components, typed against
|
|
38
|
+
# the server boundary (FR99) and carrying an opt-in `__ruactContract`
|
|
39
|
+
# (FR100). `--javascript` emits untyped `.jsx` instead.
|
|
40
|
+
# 4. app/views/posts/{index,show,new,edit}.html.erb — render the components
|
|
41
|
+
# with props from controller-set ivars (no `as_json` in the view).
|
|
42
|
+
# 5. spec/requests/posts_spec.rb — a light controller smoke spec
|
|
43
|
+
# (RSpec-only; emitted only when the host test framework is RSpec — 14.3
|
|
44
|
+
# D4 — `force`-overwriting `resource`'s same-path RSpec request stub).
|
|
45
|
+
#
|
|
46
|
+
# Prerequisite: `rails generate ruact:install` must have run first (it creates
|
|
47
|
+
# `app/javascript/.ruact/` and primes the route-driven codegen that emits the
|
|
48
|
+
# `createPost`/`updatePost`/`destroyPost` accessors the components import).
|
|
49
|
+
# rubocop:disable Metrics/ClassLength
|
|
50
|
+
# Thor requires command methods (the public tasks) and `class_option`
|
|
51
|
+
# declarations to live on the generator class itself; every non-command
|
|
52
|
+
# helper is already extracted into a module ({FormHelpers},
|
|
53
|
+
# {ShadcnPreflight}) or class ({ScaffoldAttribute}). The remaining surface
|
|
54
|
+
# is irreducible Thor command/option declarations + their view-model
|
|
55
|
+
# accessors, so the class sits a little over the default length budget.
|
|
56
|
+
class ScaffoldGenerator < Rails::Generators::NamedBase
|
|
57
|
+
source_root File.expand_path("templates", __dir__)
|
|
58
|
+
|
|
59
|
+
argument :attributes, type: :array, default: [],
|
|
60
|
+
banner: "field:type field:type"
|
|
61
|
+
|
|
62
|
+
class_option :javascript, type: :boolean, default: false,
|
|
63
|
+
desc: "Emit untyped .jsx components instead of typed .tsx (forfeits FR99/FR100)"
|
|
64
|
+
|
|
65
|
+
# Story 14.5 (FR103b) — the explicit opt-in to the Epic 10 shadcn/ui design
|
|
66
|
+
# system. Without it the scaffold emits design-system-agnostic components
|
|
67
|
+
# (Story 14.4 default); with it `create_components` renders the byte-preserved
|
|
68
|
+
# shadcn DataTable/Form/AlertDialog templates AND the dependency pre-flight
|
|
69
|
+
# ({#check_shadcn_setup}) runs. The pre-flight + `--skip-shadcn-check` are
|
|
70
|
+
# meaningful ONLY under this flag (the default path never runs them).
|
|
71
|
+
class_option :shadcn, type: :boolean, default: false,
|
|
72
|
+
desc: "Opt in to the shadcn/ui design system: emit the Epic 10 " \
|
|
73
|
+
"DataTable/Form/AlertDialog components and run the shadcn dependency pre-flight"
|
|
74
|
+
|
|
75
|
+
class_option :skip_shadcn_check, type: :boolean, default: false,
|
|
76
|
+
desc: "Under --shadcn, skip the shadcn/ui dependency pre-flight and write " \
|
|
77
|
+
"anyway (emits an in-file warning banner when @/components/ui/* is " \
|
|
78
|
+
"missing). No effect on the default (agnostic) path."
|
|
79
|
+
|
|
80
|
+
desc "Generates a complete CRUD skeleton (controller, route, ERB views, React components) on the v2 contract"
|
|
81
|
+
|
|
82
|
+
# Story 10.3 — the shadcn Form view-model helpers (import predicates, prop
|
|
83
|
+
# signature, `references` options). Module-housed so they don't register as
|
|
84
|
+
# Thor commands and to keep this class within its length budget.
|
|
85
|
+
include FormHelpers
|
|
86
|
+
|
|
87
|
+
# Story 10.5 — the shadcn dependency pre-flight's read-only helpers
|
|
88
|
+
# (detection / guidance messages / version read). Module-housed so they
|
|
89
|
+
# don't register as Thor commands; only `check_shadcn_setup` below is a
|
|
90
|
+
# command. See {ShadcnPreflight}.
|
|
91
|
+
include ShadcnPreflight
|
|
92
|
+
|
|
93
|
+
# Supported attribute types → their TS wire type (FR99 wire-union grain:
|
|
94
|
+
# string | number | boolean | null) + the plain form control kind. The rich
|
|
95
|
+
# shadcn control mapping lands in 10.2/10.3; this stays minimal.
|
|
96
|
+
TYPE_MAP = {
|
|
97
|
+
"string" => { ts: "string", control: :text },
|
|
98
|
+
"text" => { ts: "string", control: :textarea },
|
|
99
|
+
"integer" => { ts: "number", control: :number },
|
|
100
|
+
"float" => { ts: "number", control: :number },
|
|
101
|
+
"decimal" => { ts: "number", control: :number },
|
|
102
|
+
"boolean" => { ts: "boolean", control: :checkbox },
|
|
103
|
+
"date" => { ts: "string", control: :date },
|
|
104
|
+
"datetime" => { ts: "string", control: :datetime },
|
|
105
|
+
"references" => { ts: "number", control: :number }
|
|
106
|
+
}.freeze
|
|
107
|
+
|
|
108
|
+
SUPPORTED_TYPES = TYPE_MAP.keys.freeze
|
|
109
|
+
|
|
110
|
+
# Column types the `search` query's case-insensitive LIKE scope spans —
|
|
111
|
+
# matching numeric/date/boolean columns by substring is meaningless.
|
|
112
|
+
SEARCHABLE_COLUMN_TYPES = %w[string text].freeze
|
|
113
|
+
|
|
114
|
+
# Story 10.3 (AC6) — parent-set size at/under which a `references` field
|
|
115
|
+
# renders an eager shadcn `<Select>` of controller-provided options. Above
|
|
116
|
+
# it, a server-search combobox is the right control; the generated Form
|
|
117
|
+
# leaves a documented opt-in trail (the parent-options query it would query
|
|
118
|
+
# is out of this scaffold's scope — the parent model isn't scaffolded here).
|
|
119
|
+
# Adjustable: re-run with a different value and re-generate, or edit the
|
|
120
|
+
# emitted controller's `.limit(...)`.
|
|
121
|
+
REFERENCE_OPTIONS_LIMIT = 100
|
|
122
|
+
|
|
123
|
+
# Documentation anchor referenced by the unknown-type error message (AC4).
|
|
124
|
+
DOCS_POINTER = "https://github.com/luizcg/ruact/blob/main/website/docs/api/scaffold.md#attribute-types"
|
|
125
|
+
|
|
126
|
+
# Story 10.5 (AC1, AC2, AC4) — the shadcn/ui dependency PRE-FLIGHT: detect
|
|
127
|
+
# the host's shadcn state (complete / missing / partial) and either proceed
|
|
128
|
+
# (complete), or print copy-pasteable `npx shadcn` guidance and ABORT
|
|
129
|
+
# before any write (missing / partial), unless `--skip-shadcn-check`
|
|
130
|
+
# bypasses the abort. The generator NEVER auto-runs `npx`/`npm` (AC4 — no
|
|
131
|
+
# surprising network/install behavior, critical for CI). Aborting via
|
|
132
|
+
# `raise Thor::Error` mirrors `assert_supported_attribute_types!`.
|
|
133
|
+
#
|
|
134
|
+
# Story 14.5 (FR103b, D1) — RE-PROMOTED to a Thor command (Story 14.4 had
|
|
135
|
+
# de-registered it via `remove_command` so the default path never aborted).
|
|
136
|
+
# It is now `--shadcn`-GATED: the `return unless shadcn?` guard makes it a
|
|
137
|
+
# no-op on the default (agnostic) path — a fresh app with no shadcn still
|
|
138
|
+
# scaffolds without a pre-flight or abort — while under `--shadcn` it runs
|
|
139
|
+
# the byte-preserved {ShadcnPreflight} detection / guidance / abort (zero
|
|
140
|
+
# partial state, `raise Thor::Error` on missing/partial unless
|
|
141
|
+
# `--skip-shadcn-check`). Declared FIRST among the command tasks (before
|
|
142
|
+
# {#generate_rails_resource}) so the `--shadcn` abort precedes every write.
|
|
143
|
+
def check_shadcn_setup
|
|
144
|
+
return unless shadcn?
|
|
145
|
+
|
|
146
|
+
run_shadcn_preflight! # {ShadcnPreflight} — detection / guidance / abort
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Story 14.3 (FR102, AC1) — delegate model + migration + the `resources`
|
|
150
|
+
# route + host-framework test stubs to Rails' own public `resource`
|
|
151
|
+
# generator, THEN let the overlay tasks below win where they intersect
|
|
152
|
+
# (the controller force-overwrites resource's bare one; the route guard
|
|
153
|
+
# no-ops on the already-drawn line). Declared right AFTER the `--shadcn`-gated
|
|
154
|
+
# pre-flight {#check_shadcn_setup} (Story 14.5) so a `--shadcn` missing-setup
|
|
155
|
+
# abort precedes this delegation (zero partial state); on the default path
|
|
156
|
+
# the pre-flight no-ops and this runs first among the writing tasks. A bad
|
|
157
|
+
# input still fails loud at `parse_attributes!`, before any task runs (zero
|
|
158
|
+
# partial state). The real
|
|
159
|
+
# sub-generator call lives behind the stubbable `invoke_rails_resource`
|
|
160
|
+
# seam (mirroring Story 14.1's `run_npm_install`) — the unit specs stub it
|
|
161
|
+
# (a bare `mktmpdir` is not a booted Rails app, so a real `invoke
|
|
162
|
+
# "resource"` cannot run there); the live end-to-end delegation is proven
|
|
163
|
+
# by Story 14.6's clean-room Docker E2E.
|
|
164
|
+
def generate_rails_resource
|
|
165
|
+
invoke_rails_resource(name, *raw_attribute_args)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Story 14.3 (AC4, D2) — `force: true` so ruact's v2 controller silently
|
|
169
|
+
# overwrites the empty 7-action controller `resource` emits (no Thor
|
|
170
|
+
# "conflict?" prompt, no leftover bare controller). The overlay is the
|
|
171
|
+
# real controller; resource's controller-test stub is harmless and stands
|
|
172
|
+
# as the Rails-parity controller test.
|
|
173
|
+
def create_controller
|
|
174
|
+
template "controller.rb.tt", File.join("app/controllers", "#{plural_file_name}_controller.rb"),
|
|
175
|
+
force: true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# AC5 — idempotent on re-run: Rails owns the insertion, but its `route`
|
|
179
|
+
# action is not duplicate-aware across runs, so guard on the drawn line
|
|
180
|
+
# first. (A commented `collection { post :publish }` FR96 anchor is NOT
|
|
181
|
+
# injected here — Rails' `route` action only takes a single statement; the
|
|
182
|
+
# publish demo lives commented in the controller, where it reads cleanly.)
|
|
183
|
+
def add_resource_route
|
|
184
|
+
routes_file = Pathname(destination_root).join("config/routes.rb")
|
|
185
|
+
if routes_file.exist? && routes_file.read.match?(/^\s*resources +:#{Regexp.escape(plural_name)}\b/)
|
|
186
|
+
say_status "skip", "resources :#{plural_name} already routed", :yellow
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
route "resources :#{plural_name}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# AC5 — the client-driven read path. Emits the resource query
|
|
194
|
+
# (`<Plural>Query < ApplicationQuery` with a `search(q:)` method) in BOTH
|
|
195
|
+
# `.tsx` and `.jsx` modes (the query is server-side Ruby; the language flag
|
|
196
|
+
# only governs the React component). The `ApplicationQuery` base is created
|
|
197
|
+
# idempotently — `ruact:install` does NOT ship it, and a second scaffold in
|
|
198
|
+
# the same app must NOT clobber a customized base.
|
|
199
|
+
def create_queries
|
|
200
|
+
template "queries/query.rb.tt", File.join("app/queries", "#{plural_file_name}_query.rb")
|
|
201
|
+
|
|
202
|
+
application_query = File.join("app/queries", "application_query.rb")
|
|
203
|
+
return if Pathname(destination_root).join(application_query).exist?
|
|
204
|
+
|
|
205
|
+
template "queries/application_query.rb.tt", application_query
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# AC5 — mount the resource query so its `search` method becomes the named
|
|
209
|
+
# GET route the codegen exports as `search` (consumed by `useQuery`).
|
|
210
|
+
# Idempotent on re-run: guard on the drawn `ruact_queries <Plural>Query`
|
|
211
|
+
# line first (sibling of {#add_resource_route}'s `resources :posts` guard).
|
|
212
|
+
def add_query_route
|
|
213
|
+
routes_file = Pathname(destination_root).join("config/routes.rb")
|
|
214
|
+
if routes_file.exist? && routes_file.read.match?(/^\s*ruact_queries\s+#{Regexp.escape(query_class_name)}\b/)
|
|
215
|
+
say_status "skip", "ruact_queries #{query_class_name} already routed", :yellow
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
route "ruact_queries #{query_class_name}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def create_views
|
|
223
|
+
%w[index show new edit].each do |view|
|
|
224
|
+
template "views/#{view}.html.erb.tt", File.join("app/views", plural_file_name, "#{view}.html.erb")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Story 14.4 (FR103) / Story 14.5 (FR103b) — the component templates are the
|
|
229
|
+
# ONLY thing the `--shadcn` opt-in flips:
|
|
230
|
+
# * default (no flag) → design-system-AGNOSTIC components (plain native
|
|
231
|
+
# HTML, Rails-default CSS, no shadcn/ui, no Tailwind) from
|
|
232
|
+
# `components/agnostic/` (Story 14.4).
|
|
233
|
+
# * `--shadcn` → the byte-preserved Epic 10 shadcn DataTable / Form /
|
|
234
|
+
# AlertDialog templates from `components/` (Story 14.5 — restored from
|
|
235
|
+
# behind the flag, NOT rewritten).
|
|
236
|
+
# `component_ext` (`.tsx` / `.jsx`) is selected independently so
|
|
237
|
+
# `--shadcn --javascript` composes (untyped `.jsx` shadcn output). Everything
|
|
238
|
+
# else (controller, route, query, views, smoke spec) is identical on both
|
|
239
|
+
# paths.
|
|
240
|
+
def create_components
|
|
241
|
+
dir = shadcn? ? "components" : "components/agnostic"
|
|
242
|
+
template "#{dir}/List.tsx.tt",
|
|
243
|
+
File.join("app/javascript/components", "#{class_name}List.#{component_ext}")
|
|
244
|
+
template "#{dir}/Form.tsx.tt",
|
|
245
|
+
File.join("app/javascript/components", "#{class_name}Form.#{component_ext}")
|
|
246
|
+
template "#{dir}/DeleteDialog.tsx.tt",
|
|
247
|
+
File.join("app/javascript/components", "#{class_name}DeleteDialog.#{component_ext}")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Story 14.3 (AC2, AC4, D4) — ruact's v2-contract request spec is
|
|
251
|
+
# authoritative, but it is RSpec-only (`require "rails_helper"`,
|
|
252
|
+
# `type: :request`). Two reconciliations:
|
|
253
|
+
# 1. Framework gate — emit ONLY when the host test framework is RSpec, so
|
|
254
|
+
# a Minitest app never gets a broken `rails_helper`-requiring file
|
|
255
|
+
# (the delegated Rails Minitest model/controller tests stand alone).
|
|
256
|
+
# 2. RSpec-path collision — with `rspec-rails`, `resource` emits a request
|
|
257
|
+
# spec at this SAME path; `force: true` makes ruact's v2 spec win, so
|
|
258
|
+
# there is exactly one request spec and it is ruact's.
|
|
259
|
+
def create_smoke_spec
|
|
260
|
+
return unless rspec_smoke_spec?
|
|
261
|
+
|
|
262
|
+
template "request_spec.rb.tt", File.join("spec/requests", "#{plural_file_name}_spec.rb"),
|
|
263
|
+
force: true
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
no_tasks do # rubocop:disable Metrics/BlockLength
|
|
267
|
+
# Rails' `NamedBase#initialize` calls `parse_attributes!` to turn each raw
|
|
268
|
+
# `field:type` arg into a `GeneratedAttribute`. We hook it to enforce the
|
|
269
|
+
# TS-type allowlist FIRST (AC4), because Rails' own unknown-type handling
|
|
270
|
+
# is wrong for us on two counts: (1) its error neither lists ruact's
|
|
271
|
+
# supported types nor points at the docs, and (2) once ActiveRecord is
|
|
272
|
+
# loaded, `GeneratedAttribute.parse` reaches for a DB connection to
|
|
273
|
+
# validate an unrecognized type (raising `ConnectionNotDefined` instead of
|
|
274
|
+
# a usable message). Failing here — before `super`, before any task runs —
|
|
275
|
+
# gives the clean message AND guarantees no partial output (construction
|
|
276
|
+
# aborts). Raised as a `Thor::Error` so the CLI prints it without a Ruby
|
|
277
|
+
# backtrace and exits non-zero.
|
|
278
|
+
def parse_attributes!
|
|
279
|
+
assert_flat_resource_name!
|
|
280
|
+
assert_supported_attribute_types!
|
|
281
|
+
# Story 14.3 — capture the RAW `field:type` strings before `super`
|
|
282
|
+
# turns `attributes` into Rails `GeneratedAttribute`s. These verbatim
|
|
283
|
+
# args are what `resource` re-parses into its own attributes (correct
|
|
284
|
+
# migration column types, length/index modifiers, polymorphic refs),
|
|
285
|
+
# so the delegation does NOT reconstruct from ruact's view-models.
|
|
286
|
+
@raw_attribute_args = Array(attributes).map(&:to_s)
|
|
287
|
+
super
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Story 14.3 — the verbatim `field:type` args passed to the resource
|
|
291
|
+
# generator (captured in {#parse_attributes!} before `super` rewrote
|
|
292
|
+
# `attributes` into `GeneratedAttribute`s).
|
|
293
|
+
def raw_attribute_args
|
|
294
|
+
@raw_attribute_args || []
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Story 14.3 — the stubbable delegation seam (AC1, AC5). Isolated so the
|
|
298
|
+
# unit specs can assert/stub the sub-generator invocation without a
|
|
299
|
+
# booted Rails app (mirrors Story 14.1's `run_npm_install`). Uses only
|
|
300
|
+
# the PUBLIC `resource` generator surface via Thor's in-generator
|
|
301
|
+
# `invoke`, which shares this generator's `destination_root`/shell and
|
|
302
|
+
# participates in the host's `config.generators` hooks (so the host's
|
|
303
|
+
# `test_framework` decides Minitest-vs-RSpec stub form — AC2).
|
|
304
|
+
def invoke_rails_resource(resource_name, *attribute_args)
|
|
305
|
+
invoke "resource", [resource_name, *attribute_args]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Story 14.3 (AC2, D4) — whether to emit ruact's RSpec-only v2 smoke
|
|
309
|
+
# spec. Honors the host's configured test framework: emit for `:rspec`;
|
|
310
|
+
# skip for Minitest (`:test_unit`/`:minitest`) so no broken
|
|
311
|
+
# `rails_helper`-requiring file lands there. When the framework can't be
|
|
312
|
+
# determined (e.g. no booted app — the unit harness), default to
|
|
313
|
+
# emitting: an RSpec request spec is the scaffold's documented smoke
|
|
314
|
+
# default, and a real host always resolves a concrete framework.
|
|
315
|
+
def rspec_smoke_spec?
|
|
316
|
+
case host_test_framework
|
|
317
|
+
when :rspec, nil then true
|
|
318
|
+
else false
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Best-effort read of the host app's configured generators
|
|
323
|
+
# `test_framework` (the same signal `resource`'s test hook reads). `nil`
|
|
324
|
+
# when there is no booted application or the option is unset. Defensive:
|
|
325
|
+
# never raises out into the generator run.
|
|
326
|
+
def host_test_framework
|
|
327
|
+
app = Rails.application
|
|
328
|
+
return nil unless app
|
|
329
|
+
|
|
330
|
+
app.config.generators.options.dig(:rails, :test_framework)
|
|
331
|
+
rescue StandardError
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# 10.1 generates a FLAT resource (`Post`). A namespaced name (`Admin::Post`
|
|
336
|
+
# / `admin/post`) would desync across the emitted files — the controller
|
|
337
|
+
# file lands at the un-namespaced `posts_controller.rb` path while the
|
|
338
|
+
# class text is `Admin::PostsController` (Zeitwerk-invalid), component
|
|
339
|
+
# filenames/imports become `Admin::PostList` / `createAdmin::Post` (not
|
|
340
|
+
# valid TS, and not the route codegen's `createAdminPost`). Rather than
|
|
341
|
+
# emit silently-broken output, fail loud: namespaced scaffolds are a
|
|
342
|
+
# later concern. `name`/`class_name` are already assigned by NamedBase's
|
|
343
|
+
# `assign_names!` (it runs before `parse_attributes!`).
|
|
344
|
+
def assert_flat_resource_name!
|
|
345
|
+
return unless name.to_s.include?("/") || class_name.include?("::")
|
|
346
|
+
|
|
347
|
+
raise Thor::Error, <<~MSG.chomp
|
|
348
|
+
ruact:scaffold — namespaced resources (e.g. `#{name}`) are not supported yet.
|
|
349
|
+
Generate a flat resource, e.g. `rails generate ruact:scaffold Post title:string`.
|
|
350
|
+
MSG
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def assert_supported_attribute_types!
|
|
354
|
+
unknown = Array(attributes).filter_map do |arg|
|
|
355
|
+
name, type = arg.to_s.split(":", 2)
|
|
356
|
+
type = (type || "string").split("{").first
|
|
357
|
+
"#{name}:#{type}" unless SUPPORTED_TYPES.include?(type)
|
|
358
|
+
end
|
|
359
|
+
return if unknown.empty?
|
|
360
|
+
|
|
361
|
+
raise Thor::Error, <<~MSG.chomp
|
|
362
|
+
ruact:scaffold — unknown attribute type(s): #{unknown.join(', ')}
|
|
363
|
+
Supported types: #{SUPPORTED_TYPES.join(', ')}
|
|
364
|
+
See the attribute type-mapping table: #{DOCS_POINTER}
|
|
365
|
+
MSG
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Parsed attribute view-models (memoized), built from the
|
|
369
|
+
# `GeneratedAttribute`s Rails produced (their type allowlist was already
|
|
370
|
+
# enforced in {#parse_attributes!}). `references` columns address `<name>_id`.
|
|
371
|
+
def scaffold_attributes
|
|
372
|
+
@scaffold_attributes ||= attributes.map do |arg|
|
|
373
|
+
name, type = arg.to_s.split(":", 2)
|
|
374
|
+
ScaffoldAttribute.new(name, (type || "string").split("{").first)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def typescript?
|
|
379
|
+
!options[:javascript]
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Story 14.5 (FR103b) — the explicit shadcn opt-in. Gates BOTH the
|
|
383
|
+
# component-template selection ({#create_components}) and the dependency
|
|
384
|
+
# pre-flight ({#check_shadcn_setup}); false (default) → the agnostic path.
|
|
385
|
+
def shadcn?
|
|
386
|
+
options[:shadcn]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def component_ext
|
|
390
|
+
typescript? ? "tsx" : "jsx"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# `post` → `posts` file stem for nested paths (controller + views dir).
|
|
394
|
+
def plural_file_name
|
|
395
|
+
file_name.pluralize
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The Rails resource controller is PLURAL (`PostsController` in
|
|
399
|
+
# `posts_controller.rb`) — Zeitwerk requires the constant to match the
|
|
400
|
+
# pluralized path. `class_name` is the singular MODEL constant (`Post`),
|
|
401
|
+
# so the controller class name is its pluralization (namespace-safe:
|
|
402
|
+
# `Admin::Post` → `Admin::Posts`).
|
|
403
|
+
def controller_class_name
|
|
404
|
+
class_name.pluralize
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# The read-side query class — PLURAL, mirroring the golden `PostsQuery`
|
|
408
|
+
# (file `posts_query.rb`) and Zeitwerk's path↔constant rule. Mounted via
|
|
409
|
+
# `ruact_queries <Plural>Query`; its `search` method becomes `GET /q/search`.
|
|
410
|
+
def query_class_name
|
|
411
|
+
"#{class_name.pluralize}Query"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# The JS import alias for the query's `search` accessor. The codegen
|
|
415
|
+
# exports a generic `search` (from `<Plural>Query#search`); the component
|
|
416
|
+
# aliases it `search<Plural>` to avoid a bare-`search` collision — exactly
|
|
417
|
+
# as the golden does (`search as searchPosts`).
|
|
418
|
+
def js_search_alias
|
|
419
|
+
"search#{class_name.pluralize}"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# The columns the search `LIKE` scope spans — string/text only (a
|
|
423
|
+
# case-insensitive match over numeric/date columns is meaningless).
|
|
424
|
+
def searchable_attributes
|
|
425
|
+
scaffold_attributes.select { |attr| SEARCHABLE_COLUMN_TYPES.include?(attr.type) }
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# FR99 — the generated `type <Model>Row` body, e.g.
|
|
429
|
+
# "id: number; title: string | null; published: boolean | null". Attribute
|
|
430
|
+
# columns are `| null`: a scaffolded Rails column is nullable by default and
|
|
431
|
+
# the generator has no schema access to prove otherwise, so the honest type
|
|
432
|
+
# is the full FR99 wire union (`string | number | boolean | null`). `id` (the
|
|
433
|
+
# PK) is never null. The components null-guard these (`?? ""` / `Boolean(...)`).
|
|
434
|
+
def ts_row_fields
|
|
435
|
+
fields = scaffold_attributes.map { |attr| "#{attr.column_name}: #{attr.ts_type} | null" }
|
|
436
|
+
(["id: number"] + fields).join("; ")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# A Ruby hash literal serializing +var+ to plain row values (string keys),
|
|
440
|
+
# used by the index/edit ERB views — no `as_json` in the view. Each value
|
|
441
|
+
# is shaped so the wire value matches both the declared FR99 `<Model>Row`
|
|
442
|
+
# type AND the form input that binds it (see {#row_value_expr}).
|
|
443
|
+
def serialized_row(var)
|
|
444
|
+
pairs = [%("id" => #{var}.id)]
|
|
445
|
+
pairs += scaffold_attributes.map { |attr| %("#{attr.column_name}" => #{row_value_expr(var, attr)}) }
|
|
446
|
+
"{ #{pairs.join(', ')} }"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# The Ruby expression that reads +attr+ off +var+ for the serialized row,
|
|
450
|
+
# coerced (nil-safe) to the type its FR99 wire type + form control expect:
|
|
451
|
+
# - date → `&.iso8601` (`YYYY-MM-DD`, a `<input type=date>`-valid string)
|
|
452
|
+
# - datetime → `&.strftime("%Y-%m-%dT%H:%M")` (a `<input type=datetime-local>`-valid string;
|
|
453
|
+
# a raw `Time#iso8601` carries seconds + offset the control rejects)
|
|
454
|
+
# - decimal → `&.to_f` (BigDecimal serializes as a STRING; the row type is `number`)
|
|
455
|
+
# - everything else is already a wire-correct scalar.
|
|
456
|
+
def row_value_expr(var, attr)
|
|
457
|
+
base = "#{var}.#{attr.column_name}"
|
|
458
|
+
case attr.control
|
|
459
|
+
when :date then "#{base}&.iso8601"
|
|
460
|
+
when :datetime then %(#{base}&.strftime("%Y-%m-%dT%H:%M"))
|
|
461
|
+
else attr.type == "decimal" ? "#{base}&.to_f" : base
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Story 10.4 (AC1) — the record attribute whose value titles the delete
|
|
466
|
+
# confirmation ("Delete '<title>'?"). No universal "title" column exists,
|
|
467
|
+
# so pick the first string-ish display attribute deterministically: the
|
|
468
|
+
# first `string` column, else the first `text`, else the PK `id` (always
|
|
469
|
+
# present, never null). Returns the wire `column_name`.
|
|
470
|
+
def display_attribute
|
|
471
|
+
attr = scaffold_attributes.find { |a| a.type == "string" } ||
|
|
472
|
+
scaffold_attributes.find { |a| a.type == "text" }
|
|
473
|
+
attr ? attr.column_name : "id"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# A valid example value (as a Ruby source literal) for the smoke spec.
|
|
477
|
+
def sample_value(attr)
|
|
478
|
+
case attr.type
|
|
479
|
+
when "integer", "references" then "1"
|
|
480
|
+
when "float", "decimal" then "1.5"
|
|
481
|
+
when "boolean" then "true"
|
|
482
|
+
when "date" then '"2026-01-01"'
|
|
483
|
+
when "datetime" then '"2026-01-01T00:00:00"'
|
|
484
|
+
else %("Sample #{attr.name}")
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
# rubocop:enable Metrics/ClassLength
|
|
490
|
+
end
|
|
491
|
+
end
|