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.
- checksums.yaml +4 -4
- data/.forgejo/workflows/full-gate.yml +4 -2
- data/.gitignore +6 -2
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +68 -0
- data/Gemfile +4 -0
- data/Rakefile +52 -11
- data/app/controllers/inline_forms_controller.rb +15 -0
- data/app/views/inline_forms/_new.html.erb +4 -1
- data/app/views/inline_forms/_pending_migration_row.html.erb +15 -0
- data/app/views/inline_forms/_show.html.erb +4 -1
- data/app/views/inline_forms/field_pending.html.erb +10 -0
- data/lib/generators/inline_forms_addto/USAGE +31 -0
- data/lib/generators/inline_forms_addto_generator.rb +198 -26
- data/lib/inline_forms/attribute_list.rb +191 -0
- data/lib/inline_forms/gem_files.rb +26 -0
- data/lib/inline_forms/schema_apply.rb +85 -0
- data/lib/inline_forms/schema_intent.rb +76 -0
- data/lib/inline_forms/schema_label.rb +65 -0
- data/lib/inline_forms/schema_preview.rb +135 -0
- data/lib/inline_forms/version.rb +1 -1
- data/lib/inline_forms.rb +86 -0
- data/lib/locales/inline_forms.en.yml +2 -0
- data/lib/locales/inline_forms.nl.yml +2 -0
- data/test/attribute_list_test.rb +165 -0
- data/test/dummy/app/controllers/gizmos_controller.rb +4 -0
- data/test/dummy/app/models/gizmo.rb +25 -0
- data/test/dummy/config/application.rb +3 -0
- data/test/dummy/config/routes.rb +9 -0
- data/test/inline_forms_addto_generator_test.rb +220 -0
- data/test/integration/pending_migration_gate_test.rb +75 -0
- data/test/integration/schema_batch_pipeline_test.rb +189 -0
- data/test/integration/schema_edit_test.rb +141 -0
- data/test/integration/schema_preview_test.rb +65 -0
- data/test/integration_test_helper.rb +41 -0
- data/test/schema_apply_test.rb +73 -0
- data/test/schema_intent_test.rb +53 -0
- data/test/schema_label_test.rb +61 -0
- 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/#{
|
|
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
|
|
209
|
-
# `
|
|
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
|
-
|
|
212
|
-
|
|
253
|
+
path = File.join(destination_root, model_file_path)
|
|
254
|
+
content = File.read(path)
|
|
213
255
|
|
|
214
|
-
if
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|