inline_forms 8.1.40 → 8.1.42

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.forgejo/workflows/full-gate.yml +4 -2
  3. data/.gitignore +6 -2
  4. data/.rubocop.yml +1 -0
  5. data/CHANGELOG.md +44 -0
  6. data/CLAUDE.md +68 -0
  7. data/Gemfile +4 -0
  8. data/Rakefile +52 -11
  9. data/app/controllers/inline_forms_controller.rb +15 -0
  10. data/app/views/inline_forms/_new.html.erb +4 -1
  11. data/app/views/inline_forms/_pending_migration_row.html.erb +15 -0
  12. data/app/views/inline_forms/_show.html.erb +4 -1
  13. data/app/views/inline_forms/field_pending.html.erb +10 -0
  14. data/lib/generators/inline_forms_addto/USAGE +31 -0
  15. data/lib/generators/inline_forms_addto_generator.rb +198 -26
  16. data/lib/inline_forms/attribute_list.rb +191 -0
  17. data/lib/inline_forms/gem_files.rb +26 -0
  18. data/lib/inline_forms/schema_apply.rb +85 -0
  19. data/lib/inline_forms/schema_intent.rb +76 -0
  20. data/lib/inline_forms/schema_label.rb +65 -0
  21. data/lib/inline_forms/schema_preview.rb +135 -0
  22. data/lib/inline_forms/version.rb +1 -1
  23. data/lib/inline_forms.rb +86 -0
  24. data/lib/locales/inline_forms.en.yml +2 -0
  25. data/lib/locales/inline_forms.nl.yml +2 -0
  26. data/test/attribute_list_test.rb +165 -0
  27. data/test/dummy/app/controllers/gizmos_controller.rb +4 -0
  28. data/test/dummy/app/models/gizmo.rb +25 -0
  29. data/test/dummy/config/application.rb +3 -0
  30. data/test/dummy/config/routes.rb +9 -0
  31. data/test/inline_forms_addto_generator_test.rb +220 -0
  32. data/test/integration/pending_migration_gate_test.rb +75 -0
  33. data/test/integration/schema_batch_pipeline_test.rb +189 -0
  34. data/test/integration/schema_edit_test.rb +141 -0
  35. data/test/integration/schema_preview_test.rb +65 -0
  36. data/test/integration_test_helper.rb +41 -0
  37. data/test/schema_apply_test.rb +73 -0
  38. data/test/schema_intent_test.rb +53 -0
  39. data/test/schema_label_test.rb +61 -0
  40. metadata +29 -1
@@ -33,11 +33,27 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
33
33
  INSTALL_TIME_ONLY_NAMES = %w[_no_model _no_migration _id _enabled].freeze
34
34
  REPLACE_ONLY_NAMES = %w[_presentation _list_order _list_search _order].freeze
35
35
 
36
+ # Form elements that render a set of choices and therefore need a values
37
+ # hash as the 3rd element of their attribute_list row. Emitting the bare
38
+ # 2-element row for these raises at show time, so we insert a placeholder
39
+ # hash and warn the user to fill it in. Canonical list lives in InlineForms
40
+ # (shared with the schema GUI/preview).
41
+ VALUE_BEARING_ELEMENTS = InlineForms::VALUE_BEARING_FORM_ELEMENTS
42
+
43
+ # attribute_list rows whose form element (or name) marks the start of the
44
+ # trailing "metadata" block. The smart-default placement inserts new rows
45
+ # above this block instead of at the very end of the list.
46
+ METADATA_ROW_NAMES = %w[created_at updated_at].freeze
47
+
36
48
  argument :attributes, type: :array, banner: "[name:form_element]..."
37
49
  class_option :allow_unknown, type: :boolean, default: false,
38
50
  desc: "Allow unknown field types (legacy behavior: comment generated lines instead of failing)."
39
51
  class_option :replace, type: :boolean, default: false,
40
52
  desc: "Replace existing _presentation/_list_order/_list_search instead of skipping."
53
+ class_option :before, type: :string, default: nil,
54
+ desc: "Insert the new attribute_list row before this existing attribute name."
55
+ class_option :after, type: :string, default: nil,
56
+ desc: "Insert the new attribute_list row after this existing attribute name."
41
57
 
42
58
  source_root File.expand_path("inline_forms_addto/templates", __dir__)
43
59
 
@@ -94,6 +110,10 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
94
110
  next if INSTALL_TIME_ONLY_NAMES.include?(attribute.name)
95
111
  next if REPLACE_ONLY_NAMES.include?(attribute.name)
96
112
  next unless attribute.migration?
113
+ # A :header is a display-only separator in the attribute list; it has
114
+ # no backing column, so it must not emit a migration line (otherwise
115
+ # `foo:header` created a pointless `foo` string column).
116
+ next if attribute.attribute_type == :header
97
117
 
98
118
  if attribute.column_type == :belongs_to
99
119
  @migration_lines << " add_reference :#{table_name}, :#{attribute.name}, foreign_key: true\n"
@@ -106,7 +126,7 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
106
126
  return if @migration_lines.empty?
107
127
 
108
128
  template "add_columns_migration.erb",
109
- "db/migrate/#{time_stamp}_#{migration_file_basename}.rb"
129
+ "db/migrate/#{unique_time_stamp}_#{migration_file_basename}.rb"
110
130
  end
111
131
 
112
132
  def update_model
@@ -146,6 +166,20 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
146
166
  end
147
167
  end
148
168
 
169
+ # Run last (Thor invokes public methods in definition order). The model
170
+ # row was injected above, but it references a column that does not exist
171
+ # until the generated migration is applied; rendering the model before
172
+ # then raises. Remind the user loudly. No-op when no migration was written
173
+ # (e.g. only has_many/rich_text/habtm additions).
174
+ def remind_to_migrate
175
+ return if @migration_lines.nil? || @migration_lines.empty?
176
+
177
+ say ""
178
+ say " Next step: apply the generated migration before rendering #{model_class_name}:", :yellow
179
+ say " bundle exec rails db:migrate", :yellow
180
+ say " (the attribute_list row was added now, but its column only exists after migrating.)", :yellow
181
+ end
182
+
149
183
  private
150
184
 
151
185
  def model_file_name
@@ -204,43 +238,181 @@ class InlineFormsAddtoGenerator < Rails::Generators::NamedBase
204
238
  end
205
239
 
206
240
  # Inserts a new row into the existing `inline_forms_attribute_list`
207
- # method. Falls back to appending a fresh method when the model has
208
- # only `attr_writer :inline_forms_attribute_list` (inherited from
209
- # `ApplicationRecord`) and no body.
241
+ # method. Falls back to appending a fresh method when the model has no
242
+ # generator-shaped list (e.g. only the inherited
243
+ # `attr_writer :inline_forms_attribute_list` and no body).
244
+ #
245
+ # Row-aware, not regex-greedy: we locate the `[` after
246
+ # `@inline_forms_attribute_list ||=` and bracket-depth scan to its
247
+ # matching `]`, so the edit survives `].freeze`, trailing comments, and
248
+ # any methods defined after the list. Placement honors `--after`/`--before`
249
+ # (by attribute name) and otherwise lands above the trailing metadata block
250
+ # (`:info` rows / created_at / updated_at, and a `:header` directly above
251
+ # them) instead of after it.
210
252
  def add_attribute_list_row!(attribute)
211
- content = File.read(File.join(destination_root, model_file_path))
212
- row = " [ :#{attribute.name}, :#{attribute.attribute_type} ],\n"
253
+ path = File.join(destination_root, model_file_path)
254
+ content = File.read(path)
213
255
 
214
- if content.match?(/@inline_forms_attribute_list \|\|=\s*\[/)
215
- if content.match?(/\[\s*:#{Regexp.escape(attribute.name)}\s*,/)
216
- say_status :identical, "#{model_file_path}: row :#{attribute.name}", :blue
217
- return
218
- end
219
- # Insert before the closing `]` of the array, keeping appended order.
220
- # Match against the file's full text and write it back so we work
221
- # around Thor's `gsub_file` block-arg quirk (the block sees only the
222
- # full match, no $1/$2). MatchData captures are explicit here.
223
- m = content.match(/(@inline_forms_attribute_list \|\|=\s*\[(?:.|\n)*?)(\n\s*\]\s*\n\s*end)/)
224
- if m
225
- replacement = m[1] + "\n" + row.chomp + m[2]
226
- new_content = content.sub(m[0], replacement)
227
- File.write(File.join(destination_root, model_file_path), new_content)
228
- say_status :insert, "#{model_file_path}: row :#{attribute.name}", :green
229
- else
230
- say_status :warn, "#{model_file_path}: could not locate end of @inline_forms_attribute_list, skipping :#{attribute.name}", :yellow
231
- end
232
- else
256
+ # Idempotent: skip if a row for this attribute already exists anywhere.
257
+ if content.match?(/\[\s*:#{Regexp.escape(attribute.name)}\s*,/)
258
+ say_status :identical, "#{model_file_path}: row :#{attribute.name}", :blue
259
+ return
260
+ end
261
+
262
+ bounds = attribute_list_bounds(content)
263
+ unless bounds
233
264
  say_status :warn, "#{model_file_path}: no inline_forms_attribute_list found, appending a fresh method", :yellow
234
265
  body = <<~RUBY.gsub(/^/, " ")
235
266
 
236
267
  def inline_forms_attribute_list
237
268
  @inline_forms_attribute_list ||= [
238
- [ :#{attribute.name}, :#{attribute.attribute_type} ],
269
+ #{attribute_row_literal(attribute)},
239
270
  ]
240
271
  end
241
272
  RUBY
242
273
  inject_into_class model_file_path, model_class_name, body
274
+ return
275
+ end
276
+
277
+ warn_if_value_bearing!(attribute)
278
+
279
+ open_idx, close_idx = bounds
280
+ inner = content[(open_idx + 1)...close_idx]
281
+ lines = inner.lines
282
+ indent = detect_row_indent(lines)
283
+ new_line = "#{indent}#{attribute_row_literal(attribute)},\n"
284
+
285
+ insert_at, note = insertion_index(lines, attribute)
286
+
287
+ # Keep the array valid: ensure the row we insert after ends with a comma.
288
+ if insert_at.positive?
289
+ prev = lines[insert_at - 1]
290
+ if prev && prev.match?(/\S/) && !prev.match?(/,\s*\z/)
291
+ lines[insert_at - 1] = prev.sub(/(\S)(\s*)\z/, "\\1,\\2")
292
+ end
293
+ end
294
+
295
+ lines.insert(insert_at, new_line)
296
+ new_content = content[0..open_idx] + lines.join + content[close_idx..-1]
297
+ File.write(path, new_content)
298
+ say_status :insert, "#{model_file_path}: row :#{attribute.name}#{note}", :green
299
+ end
300
+
301
+ # Returns [open_bracket_index, close_bracket_index] for the
302
+ # `@inline_forms_attribute_list ||= [ ... ]` array literal, or nil when the
303
+ # model has no such list. Bracket-depth scan; nested `[ ]` (e.g. an
304
+ # options_disabled array inside a choice row) balances correctly.
305
+ def attribute_list_bounds(content)
306
+ m = content.match(/@inline_forms_attribute_list\s*\|\|=\s*\[/)
307
+ return nil unless m
308
+
309
+ open_idx = m.end(0) - 1 # index of the '[' itself
310
+ depth = 0
311
+ i = open_idx
312
+ while i < content.length
313
+ case content[i]
314
+ when "[" then depth += 1
315
+ when "]"
316
+ depth -= 1
317
+ return [ open_idx, i ] if depth.zero?
318
+ end
319
+ i += 1
320
+ end
321
+ nil
322
+ end
323
+
324
+ # Decide where the new row goes. Returns [line_index, status_note].
325
+ def insertion_index(lines, _attribute)
326
+ if (anchor = options[:after].to_s) != ""
327
+ idx = row_line_index(lines, anchor)
328
+ return [ idx + 1, " (after :#{anchor})" ] if idx
329
+ say_status :warn, "#{model_file_path}: --after #{anchor} not found; using default placement", :yellow
330
+ elsif (anchor = options[:before].to_s) != ""
331
+ idx = row_line_index(lines, anchor)
332
+ return [ idx, " (before :#{anchor})" ] if idx
333
+ say_status :warn, "#{model_file_path}: --before #{anchor} not found; using default placement", :yellow
334
+ end
335
+
336
+ if (meta = metadata_start_index(lines))
337
+ return [ meta, " (above metadata)" ]
338
+ end
339
+
340
+ [ last_row_index(lines) + 1, "" ]
341
+ end
342
+
343
+ def row_line_index(lines, name)
344
+ lines.index { |l| row_name(l) == name.to_s }
345
+ end
346
+
347
+ def row_name(line)
348
+ m = line.match(/\A\s*\[\s*:(\w+)\s*,/)
349
+ m && m[1]
350
+ end
351
+
352
+ def row_element(line)
353
+ m = line.match(/\A\s*\[\s*:\w+\s*,\s*:(\w+)/)
354
+ m && m[1]
355
+ end
356
+
357
+ # Index of the first row that starts the trailing metadata block. If a
358
+ # `:header` sits directly above that row, the header is treated as the
359
+ # block start (so the new field lands above the header, not between it and
360
+ # its info rows). nil when the list has no metadata block.
361
+ def metadata_start_index(lines)
362
+ first = nil
363
+ lines.each_with_index do |l, i|
364
+ if row_element(l) == "info" || METADATA_ROW_NAMES.include?(row_name(l))
365
+ first = i
366
+ break
367
+ end
368
+ end
369
+ return nil unless first
370
+
371
+ prev = first - 1
372
+ (prev >= 0 && row_element(lines[prev]) == "header") ? prev : first
373
+ end
374
+
375
+ def last_row_index(lines)
376
+ idx = nil
377
+ lines.each_with_index { |l, i| idx = i if row_name(l) }
378
+ idx || (lines.length - 1)
379
+ end
380
+
381
+ def detect_row_indent(lines)
382
+ lines.each do |l|
383
+ m = l.match(/\A(\s*)\[/)
384
+ return m[1] if m
385
+ end
386
+ " " # 6 spaces, matching the create-time template
387
+ end
388
+
389
+ # `[ :name, :element ]`, or `[ :name, :element, { 1 => 'one', 2 => 'two' } ]`
390
+ # for value-bearing choice elements (a placeholder the user then edits).
391
+ def attribute_row_literal(attribute)
392
+ el = attribute.attribute_type
393
+ if VALUE_BEARING_ELEMENTS.include?(el)
394
+ "[ :#{attribute.name}, :#{el}, { 1 => 'one', 2 => 'two' } ]"
395
+ else
396
+ "[ :#{attribute.name}, :#{el} ]"
397
+ end
398
+ end
399
+
400
+ def warn_if_value_bearing!(attribute)
401
+ return unless VALUE_BEARING_ELEMENTS.include?(attribute.attribute_type)
402
+
403
+ say_status :warn,
404
+ "#{model_file_path}: :#{attribute.name} is a #{attribute.attribute_type}; " \
405
+ "edit the placeholder values hash { 1 => 'one', ... }",
406
+ :yellow
407
+ end
408
+
409
+ def unique_time_stamp
410
+ base = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
411
+ dir = File.join(destination_root, "db", "migrate")
412
+ existing = Dir.glob(File.join(dir, "*.rb")).filter_map do |f|
413
+ File.basename(f)[/\A(\d{14})/, 1]&.to_i
243
414
  end
415
+ [ base, (existing.max || 0) + 1 ].max.to_s.rjust(14, "0")
244
416
  end
245
417
 
246
418
  def replace_special!(attribute)
@@ -0,0 +1,191 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module InlineForms
4
+ # A structured, mutable view over a model's `inline_forms_attribute_list`.
5
+ #
6
+ # The list has always been (and remains) an Array of positional rows:
7
+ #
8
+ # [ :name, :text_field ]
9
+ # [ :priority, :dropdown_with_values, { 1 => 'low', 2 => 'high' } ]
10
+ # [ :priority2, :dropdown_with_values, { 1 => 'low', 2 => 'high' }, [ 2 ] ]
11
+ #
12
+ # where index 0 is the attribute, index 1 the form element, index 2 an
13
+ # optional values hash, and index 3 an optional disabled-options array.
14
+ #
15
+ # Every downstream consumer relies on that shape: the views destructure
16
+ # `|attribute, form_element|`, and the helpers/validator fetch the values
17
+ # hash positionally with `list.assoc(attr)[2]`. `AttributeList` therefore
18
+ # *subclasses Array* and keeps rows as plain arrays — so an `AttributeList`
19
+ # IS a valid attribute list everywhere the old literal array was, with zero
20
+ # migration. What it adds is a small DSL/API for building and editing the
21
+ # list procedurally (append / insert / remove / move / replace) instead of
22
+ # hand-editing an array literal or regex-surgery on model source.
23
+ #
24
+ # Build fresh:
25
+ #
26
+ # InlineForms::AttributeList.build do
27
+ # header :basics
28
+ # field :name, :text_field
29
+ # field :priority, :dropdown_with_values, values: { 1 => 'low', 2 => 'high' }
30
+ # info :created_at, :updated_at
31
+ # end
32
+ #
33
+ # Wrap and edit an existing list (what generators / the staging pipeline do):
34
+ #
35
+ # InlineForms::AttributeList.wrap(model.inline_forms_attribute_list)
36
+ # .insert_after(:name, :internal_note, :text_field)
37
+ # .remove(:legacy_field)
38
+ #
39
+ # Mutating methods return self, so calls chain.
40
+ class AttributeList < Array
41
+ # Build a list from a block evaluated in the list's context.
42
+ def self.build(&block)
43
+ list = new
44
+ list.instance_eval(&block) if block
45
+ list
46
+ end
47
+
48
+ # Return `rows` as an AttributeList without copying when it already is one.
49
+ def self.wrap(rows)
50
+ return rows if rows.is_a?(self)
51
+
52
+ new(rows || [])
53
+ end
54
+
55
+ # -- builder / append -------------------------------------------------
56
+
57
+ # Append a row. `values` becomes index 2, `disabled` index 3 (disabled
58
+ # requires values, since the slot is positional).
59
+ def field(name, form_element, values: nil, disabled: nil)
60
+ push(build_row(name, form_element, values, disabled))
61
+ self
62
+ end
63
+ alias add field
64
+
65
+ # Append a `:header` pseudo-row (no column, no form element behavior).
66
+ def header(name)
67
+ field(name, :header)
68
+ end
69
+
70
+ # Append one or more read-only `:info` rows.
71
+ def info(*names)
72
+ names.each { |n| field(n, :info) }
73
+ self
74
+ end
75
+
76
+ # -- insertion --------------------------------------------------------
77
+
78
+ def insert_after(anchor, name, form_element, values: nil, disabled: nil)
79
+ insert_relative(anchor, 1, build_row(name, form_element, values, disabled))
80
+ end
81
+
82
+ def insert_before(anchor, name, form_element, values: nil, disabled: nil)
83
+ insert_relative(anchor, 0, build_row(name, form_element, values, disabled))
84
+ end
85
+
86
+ # -- removal ----------------------------------------------------------
87
+
88
+ # Remove every row for `name`. Idempotent: absent name is a no-op.
89
+ def remove(name)
90
+ sym = name.to_sym
91
+ reject! { |row| row_name(row) == sym }
92
+ self
93
+ end
94
+
95
+ # -- reordering -------------------------------------------------------
96
+
97
+ def move_after(name, anchor)
98
+ relocate(name, anchor, 1)
99
+ end
100
+
101
+ def move_before(name, anchor)
102
+ relocate(name, anchor, 0)
103
+ end
104
+
105
+ # -- replacement ------------------------------------------------------
106
+
107
+ # Swap the form element (and optional values/disabled) for an existing
108
+ # attribute, preserving its position. No-op if the attribute is absent.
109
+ def replace_element(name, form_element, values: nil, disabled: nil)
110
+ idx = index_of(name)
111
+ return self unless idx
112
+
113
+ self[idx] = build_row(name, form_element, values, disabled)
114
+ self
115
+ end
116
+
117
+ # -- queries ----------------------------------------------------------
118
+
119
+ def index_of(name)
120
+ sym = name.to_sym
121
+ index { |row| row_name(row) == sym }
122
+ end
123
+
124
+ def include_attribute?(name)
125
+ !index_of(name).nil?
126
+ end
127
+
128
+ def row_for(name)
129
+ sym = name.to_sym
130
+ find { |row| row_name(row) == sym }
131
+ end
132
+
133
+ # The form element symbol for `name`, or nil.
134
+ def form_element_for(name)
135
+ row = row_for(name)
136
+ row && row[1]
137
+ end
138
+
139
+ # The values hash for `name` (row index 2), or nil.
140
+ def values_for(name)
141
+ row = row_for(name)
142
+ row && row[2]
143
+ end
144
+
145
+ def names
146
+ map { |row| row_name(row) }
147
+ end
148
+
149
+ private
150
+
151
+ def row_name(row)
152
+ first = row.respond_to?(:first) ? row.first : nil
153
+ first&.to_sym
154
+ end
155
+
156
+ def build_row(name, form_element, values, disabled)
157
+ raise ArgumentError, "disabled: requires values: (the row slot is positional)" if !disabled.nil? && values.nil?
158
+
159
+ row = [ name.to_sym, form_element.to_sym ]
160
+ row << values unless values.nil?
161
+ row << disabled unless disabled.nil?
162
+ row
163
+ end
164
+
165
+ def insert_relative(anchor, offset, row)
166
+ idx = index_of(anchor)
167
+ raise ArgumentError, "insert anchor :#{anchor} not found in attribute list" unless idx
168
+
169
+ insert(idx + offset, row)
170
+ self
171
+ end
172
+
173
+ def relocate(name, anchor, offset)
174
+ row = row_for(name)
175
+ raise ArgumentError, "move target :#{name} not found in attribute list" unless row
176
+ raise ArgumentError, "cannot move :#{name} relative to itself" if name.to_sym == anchor.to_sym
177
+
178
+ remove(name)
179
+ idx = index_of(anchor)
180
+ raise ArgumentError, "move anchor :#{anchor} not found in attribute list" unless idx
181
+
182
+ insert(idx + offset, row)
183
+ self
184
+ end
185
+ end
186
+
187
+ # Convenience entry point mirroring AttributeList.build.
188
+ def self.attribute_list(&block)
189
+ AttributeList.build(&block)
190
+ end
191
+ end
@@ -9,6 +9,24 @@ module InlineFormsGemFiles
9
9
  inline_forms_installer.gemspec
10
10
  ].freeze
11
11
 
12
+ # The schema-GUI gem lives in its own subdirectory with its own gemspec
13
+ # (inline_forms_schema_edit/inline_forms_schema_edit.gemspec) and packages
14
+ # its files itself; exclude the whole subtree from BOTH gems here.
15
+ SCHEMA_GUI_FILE_PREFIXES = %w[
16
+ inline_forms_schema_edit/
17
+ ].freeze
18
+
19
+ # Scratch / local-notes dir. It holds working notes and, crucially, secrets
20
+ # such as stuff/forgejo-token (mode 0600, only used by CI pushes to forgejo).
21
+ # It must NEVER be packaged into a gem. Do not rely on git ignore for this:
22
+ # gem_files sweeps untracked files too, and the global excludes file that
23
+ # ignores stuff/ is per-machine (present here, absent on the release box), so
24
+ # the token leaked into `gem build` on the release machine. Exclude it hard,
25
+ # independent of any ignore configuration.
26
+ EXCLUDED_FILE_PREFIXES = %w[
27
+ stuff/
28
+ ].freeze
29
+
12
30
  module_function
13
31
 
14
32
  REPO_ROOT = File.expand_path("../..", __dir__)
@@ -32,6 +50,14 @@ module InlineFormsGemFiles
32
50
 
33
51
  files.select! { |f| File.file?(File.join(REPO_ROOT, f)) }
34
52
 
53
+ files.reject! do |f|
54
+ EXCLUDED_FILE_PREFIXES.any? { |prefix| f == prefix || f.start_with?(prefix) }
55
+ end
56
+
57
+ files.reject! do |f|
58
+ SCHEMA_GUI_FILE_PREFIXES.any? { |prefix| f == prefix || f.start_with?(prefix) }
59
+ end
60
+
35
61
  files.reject do |f|
36
62
  installer_file = INSTALLER_FILE_PREFIXES.any? { |prefix| f == prefix || f.start_with?(prefix) }
37
63
  include_installer ? !installer_file : installer_file
@@ -0,0 +1,85 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ require "shellwords"
4
+
5
+ module InlineForms
6
+ # Skeleton of the "apply" step of the staging pipeline: turn an approved
7
+ # SchemaIntent into the ordered shell commands a developer would run by hand
8
+ # (generate the migration + model edit, migrate, test, commit, restart). This
9
+ # is the rare, expensive, deliberate step — see the staging doc.
10
+ #
11
+ # The engine does NOT execute these itself by default: which host runs them
12
+ # (a rake task on a single-server box, or a CI job after a PR on a clustered
13
+ # deploy) is an app decision. #commands returns the plan for inspection;
14
+ # #run! executes it via an injectable runner (defaults to Kernel#system) and
15
+ # stops at the first failure, so an app/rake task can opt in.
16
+ class SchemaApply
17
+ STEPS = %i[generate migrate].freeze
18
+
19
+ # In-process generate-only executor used by the schema GUI: runs the addto
20
+ # generator against the app's tree (edits the model, writes the migration
21
+ # file) but never migrates. Overridable so tests can record the args
22
+ # instead of mutating the working tree.
23
+ DEFAULT_GENERATE_EXECUTOR = lambda do |args, destination_root|
24
+ require "generators/inline_forms_addto_generator"
25
+ InlineFormsAddtoGenerator.start(args, destination_root: destination_root)
26
+ end
27
+
28
+ attr_reader :intent
29
+
30
+ def initialize(intent, runner: nil)
31
+ @intent = intent
32
+ @runner = runner || ->(cmd) { system(*cmd) }
33
+ end
34
+
35
+ # Run the addto generator in-process (model edit + migration file) WITHOUT
36
+ # running db:migrate. Returns the path of the migration it created (or nil
37
+ # when the executor did not create one, e.g. a recording test executor).
38
+ # The caller (GUI) then tells the user to run `rails db:migrate` + restart;
39
+ # the pending-migration gate covers the interim.
40
+ def generate!(destination_root:, executor: DEFAULT_GENERATE_EXECUTOR)
41
+ before = addto_migrations(destination_root)
42
+ executor.call(intent.generator_args, destination_root)
43
+ # Only report a migration this run actually created. nil is correct and
44
+ # expected for a :header (no column -> no migration) and must NOT fall
45
+ # back to a pre-existing migration file.
46
+ (addto_migrations(destination_root) - before).max
47
+ end
48
+
49
+ # `rails g inline_forms_addto <Model> <attr:fe> [--after=..]`.
50
+ def generator_command
51
+ %w[bundle exec rails g inline_forms_addto] + intent.generator_args
52
+ end
53
+
54
+ def migrate_command
55
+ %w[bundle exec rails db:migrate]
56
+ end
57
+
58
+ # Ordered plan (each value is an argv array).
59
+ def commands
60
+ { generate: generator_command, migrate: migrate_command }
61
+ end
62
+
63
+ # Human-readable plan (what an app would show before the user confirms).
64
+ def preview_plan
65
+ STEPS.map { |step| "#{step}: #{Shellwords.join(commands.fetch(step))}" }
66
+ end
67
+
68
+ # Execute generate -> migrate, stopping at the first failure. Returns the
69
+ # step that failed, or nil on success. NOT run in tests (would shell out);
70
+ # an app/rake task calls this after approval + a git-clean check.
71
+ def run!
72
+ STEPS.each do |step|
73
+ ok = @runner.call(commands.fetch(step))
74
+ return step unless ok
75
+ end
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def addto_migrations(destination_root)
82
+ Dir.glob(File.join(destination_root.to_s, "db", "migrate", "*_inline_forms_add_to_*.rb"))
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,76 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ module InlineForms
4
+ # A *proposed* schema change: "add attribute X of form-element Y to model Z,
5
+ # at this position". This is the unit the end-user-facing staging pipeline
6
+ # (see stuff/2026-07-11-end-user-schema-changes-staging-and-pending-gate.md)
7
+ # drafts, previews, and finally applies.
8
+ #
9
+ # It is a plain value object — deliberately NOT an ActiveRecord model. Where
10
+ # intents are persisted (a drafts table) and edited (a GUI) is an app-level
11
+ # concern; the engine only needs the structured description plus the mapping
12
+ # to the generator that realizes it. Nothing renders from an intent at
13
+ # runtime, so it is an audit/work-queue artifact, not config-as-data.
14
+ #
15
+ # STATUS is advisory metadata for a persisting app: draft -> approved ->
16
+ # applied (or failed).
17
+ class SchemaIntent
18
+ STATUSES = %i[draft approved applied failed].freeze
19
+
20
+ attr_reader :model_name, :attribute, :form_element, :values, :disabled, :after, :before
21
+ attr_accessor :status
22
+
23
+ def initialize(model_name:, attribute:, form_element:,
24
+ values: nil, disabled: nil, after: nil, before: nil, status: :draft)
25
+ @model_name = model_name.to_s
26
+ @attribute = attribute.to_sym
27
+ @form_element = form_element.to_sym
28
+ @values = values
29
+ @disabled = disabled
30
+ @after = after&.to_sym
31
+ @before = before&.to_sym
32
+ self.status = status
33
+ end
34
+
35
+ def status=(value)
36
+ sym = value.to_sym
37
+ unless STATUSES.include?(sym)
38
+ raise ArgumentError, "unknown status #{value.inspect} (expected one of #{STATUSES.inspect})"
39
+ end
40
+
41
+ @status = sym
42
+ end
43
+
44
+ # The model class this intent targets. Raises NameError if it is not
45
+ # defined (an app should validate the name before drafting).
46
+ def model_class
47
+ model_name.constantize
48
+ end
49
+
50
+ # The `attr:form_element` token exactly as the generator expects it.
51
+ def column_token
52
+ "#{attribute}:#{form_element}"
53
+ end
54
+
55
+ # argv for `rails g inline_forms_addto <Model> <attr:form_element> [--after=..]`.
56
+ def generator_args
57
+ args = [ model_name, column_token ]
58
+ args << "--after=#{after}" if after
59
+ args << "--before=#{before}" if before
60
+ args
61
+ end
62
+
63
+ def to_h
64
+ {
65
+ model_name: model_name,
66
+ attribute: attribute,
67
+ form_element: form_element,
68
+ values: values,
69
+ disabled: disabled,
70
+ after: after,
71
+ before: before,
72
+ status: status
73
+ }
74
+ end
75
+ end
76
+ end