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
@@ -5,6 +5,7 @@ require "tmpdir"
5
5
  require "fileutils"
6
6
  require "logger"
7
7
  require "rails"
8
+ require "active_record"
8
9
  require "rails/generators"
9
10
  require "inline_forms"
10
11
  require_relative "../lib/generators/inline_forms_addto_generator"
@@ -56,6 +57,45 @@ class InlineFormsAddtoGeneratorTest < Minitest::Test
56
57
  end
57
58
  RUBY
58
59
 
60
+ # A model whose list ends with a trailing metadata block (a :header above
61
+ # :info / timestamps rows). New rows must land above the header, not after
62
+ # the info rows.
63
+ METADATA_MODEL = <<~RUBY
64
+ class Profile < ApplicationRecord
65
+
66
+ def inline_forms_attribute_list
67
+ @inline_forms_attribute_list ||= [
68
+ [ :name, :text_field ],
69
+ [ :bio, :text_area ],
70
+ [ :header_meta, :header ],
71
+ [ :encrypted_password, :info ],
72
+ [ :created_at, :info ],
73
+ [ :updated_at, :info ],
74
+ ]
75
+ end
76
+
77
+ end
78
+ RUBY
79
+
80
+ # List closed with `].freeze` and a method defined *after* the list. The old
81
+ # greedy `]\\n end` regex could mismatch here; the bracket-depth scan must not.
82
+ FROZEN_LIST_MODEL = <<~RUBY
83
+ class Gadget < ApplicationRecord
84
+
85
+ def inline_forms_attribute_list
86
+ @inline_forms_attribute_list ||= [
87
+ [ :name, :text_field ],
88
+ [ :size, :integer_field ],
89
+ ].freeze
90
+ end
91
+
92
+ def _presentation
93
+ "\#{name}"
94
+ end
95
+
96
+ end
97
+ RUBY
98
+
59
99
  def setup
60
100
  @destination_root = Dir.mktmpdir("inline_forms_addto_generator_test")
61
101
  mkdir_p("app/models")
@@ -110,6 +150,27 @@ class InlineFormsAddtoGeneratorTest < Minitest::Test
110
150
  refute_includes(migration, "create_table")
111
151
  end
112
152
 
153
+ def test_header_adds_a_row_but_no_migration_column
154
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
155
+
156
+ capture_io { run_generator("Widget", "section:header") }
157
+
158
+ model = read("app/models/widget.rb")
159
+ assert_includes(model, "[ :section, :header ]")
160
+ # A header needs no column, so no migration should be written at all.
161
+ refute_addto_migration_for("widgets")
162
+ end
163
+
164
+ def test_header_alongside_scalar_migrates_only_the_scalar
165
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
166
+
167
+ capture_io { run_generator("Widget", "section:header", "note:string") }
168
+
169
+ migration = read_single_addto_migration_for("widgets")
170
+ assert_includes(migration, "add_column :widgets, :note, :string")
171
+ refute_includes(migration, ":section")
172
+ end
173
+
113
174
  def test_idempotent_rerun_does_not_duplicate_lines
114
175
  write_model("widget.rb", GENERATOR_SHAPED_MODEL)
115
176
 
@@ -220,12 +281,171 @@ class InlineFormsAddtoGeneratorTest < Minitest::Test
220
281
  refute_includes(model, "self.name <=> other.name")
221
282
  end
222
283
 
284
+ def test_smart_placement_lands_above_trailing_metadata_block
285
+ write_model("profile.rb", METADATA_MODEL)
286
+
287
+ capture_io { run_generator("Profile", "occupation:string") }
288
+
289
+ model = read("app/models/profile.rb")
290
+ assert_includes(model, "[ :occupation, :text_field ]")
291
+ # Above the metadata header, and therefore above every :info row.
292
+ assert(model.index("[ :occupation") < model.index("[ :header_meta"),
293
+ "occupation should be inserted above the metadata header")
294
+ assert(model.index("[ :bio") < model.index("[ :occupation"),
295
+ "occupation should stay below the existing content rows")
296
+ end
297
+
298
+ def test_frozen_list_and_trailing_method_do_not_break_insertion
299
+ write_model("gadget.rb", FROZEN_LIST_MODEL)
300
+
301
+ capture_io { run_generator("Gadget", "color:string") }
302
+
303
+ model = read("app/models/gadget.rb")
304
+ assert_includes(model, "[ :color, :text_field ]")
305
+ # Still a single, valid, frozen list; the trailing method is untouched.
306
+ assert_equal(1, model.scan("].freeze").size)
307
+ assert_includes(model, "def _presentation")
308
+ assert(model.index("[ :size") < model.index("[ :color"),
309
+ "color should be appended after the last existing row")
310
+ assert(model.index("[ :color") < model.index("].freeze"),
311
+ "color should be inside the frozen array")
312
+ refute_includes(model, "no inline_forms_attribute_list found")
313
+ end
314
+
315
+ def test_after_option_places_row_after_named_attribute
316
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
317
+
318
+ capture_io { run_generator("Widget", "occupation:string", "--after=name") }
319
+
320
+ model = read("app/models/widget.rb")
321
+ assert(model.index("[ :name") < model.index("[ :occupation"))
322
+ assert(model.index("[ :occupation") < model.index("[ :category"))
323
+ end
324
+
325
+ def test_before_option_places_row_before_named_attribute
326
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
327
+
328
+ capture_io { run_generator("Widget", "occupation:string", "--before=category") }
329
+
330
+ model = read("app/models/widget.rb")
331
+ assert(model.index("[ :occupation") < model.index("[ :category"))
332
+ end
333
+
334
+ def test_missing_anchor_warns_and_falls_back_to_default
335
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
336
+
337
+ out, _err = capture_io do
338
+ run_generator("Widget", "occupation:string", "--after=nope")
339
+ end
340
+
341
+ assert_includes(out, "--after nope not found")
342
+ model = read("app/models/widget.rb")
343
+ assert_includes(model, "[ :occupation, :text_field ]")
344
+ end
345
+
346
+ def test_value_bearing_element_gets_placeholder_hash_and_warns
347
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
348
+
349
+ out, _err = capture_io do
350
+ run_generator("Widget", "status:dropdown_with_values")
351
+ end
352
+
353
+ model = read("app/models/widget.rb")
354
+ assert_includes(model, "[ :status, :dropdown_with_values, { 1 => 'one', 2 => 'two' } ]")
355
+ assert_includes(out, "values hash")
356
+ end
357
+
358
+ def test_back_to_back_runs_produce_distinct_migration_filenames
359
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
360
+
361
+ # No sleep between runs: unique_time_stamp must still disambiguate.
362
+ capture_io { run_generator("Widget", "alpha:string") }
363
+ capture_io { run_generator("Widget", "beta:string") }
364
+
365
+ files = Dir.glob(File.join(@destination_root, "db/migrate/*_inline_forms_add_to_widgets_*.rb"))
366
+ assert_equal(2, files.size, "expected two distinct addto migrations, got #{files.inspect}")
367
+ prefixes = files.map { |f| File.basename(f)[/\A(\d{14})/, 1] }
368
+ assert_equal(prefixes.uniq.size, prefixes.size, "migration timestamps collided: #{prefixes.inspect}")
369
+ end
370
+
371
+ def test_generated_migration_applies_cleanly_to_sqlite
372
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
373
+
374
+ capture_io { run_generator("Widget", "occupation:string", "organization:belongs_to") }
375
+
376
+ migration_file = Dir.glob(
377
+ File.join(@destination_root, "db/migrate/*_inline_forms_add_to_widgets_*.rb")
378
+ ).first
379
+ refute_nil(migration_file, "expected an addto migration to be generated")
380
+
381
+ conn = apply_migration_to_memory_sqlite(migration_file, tables: %i[organizations widgets])
382
+
383
+ assert(conn.column_exists?(:widgets, :occupation), "occupation column should exist after migrate")
384
+ assert(conn.column_exists?(:widgets, :organization_id), "organization_id column should exist after migrate")
385
+ ensure
386
+ teardown_sqlite
387
+ end
388
+
223
389
  private
224
390
 
225
391
  def run_generator(*args)
226
392
  InlineForms::InlineFormsAddtoGenerator.start(args, destination_root: @destination_root)
227
393
  end
228
394
 
395
+ # Applies a generated migration file against a fresh in-memory sqlite DB on a
396
+ # dedicated connection (ScratchMigrationBase), pre-creating the referenced
397
+ # tables. Proves the emitted migration DSL is syntactically and semantically
398
+ # valid without a full Rails app — and without disturbing ActiveRecord::Base's
399
+ # connection (the engine integration tests share this process). Returns the
400
+ # scratch connection for assertions.
401
+ def apply_migration_to_memory_sqlite(path, tables:)
402
+ scratch_migration_base.establish_connection(adapter: "sqlite3", database: ":memory:")
403
+ conn = scratch_migration_base.connection
404
+ ActiveRecord::Migration.verbose = false
405
+ tables.each do |t|
406
+ conn.create_table(t, force: true) { |tbl| tbl.string :name }
407
+ end
408
+
409
+ src = File.read(path)
410
+ klass_name = src[/class\s+(\w+)\s*</, 1]
411
+ Object.send(:remove_const, klass_name.to_sym) if Object.const_defined?(klass_name.to_sym)
412
+ Object.class_eval(src)
413
+ klass = Object.const_get(klass_name)
414
+ @sqlite_migration_const = klass_name
415
+ # exec_migration(conn, dir) runs the migration against `conn` specifically,
416
+ # unlike migrate(:up) which uses ActiveRecord::Base.connection.
417
+ ActiveRecord::Migration.suppress_messages { klass.new.exec_migration(conn, :up) }
418
+ conn
419
+ end
420
+
421
+ def teardown_sqlite
422
+ if @sqlite_migration_const && Object.const_defined?(@sqlite_migration_const.to_sym)
423
+ Object.send(:remove_const, @sqlite_migration_const.to_sym)
424
+ end
425
+ @sqlite_migration_const = nil
426
+ @@scratch_migration_base&.remove_connection
427
+ rescue StandardError
428
+ nil
429
+ end
430
+
431
+ # A dedicated abstract ActiveRecord::Base subclass whose connection is the
432
+ # scratch in-memory sqlite DB. Created lazily at *runtime* (not file-load
433
+ # time): subclassing ActiveRecord::Base before the dummy app boots trips the
434
+ # railtie into resolving the wrong Rails env. Never touches Base's own
435
+ # connection, so the engine integration tests sharing this process keep the
436
+ # dummy app's in-memory schema.
437
+ @@scratch_migration_base = nil
438
+
439
+ def scratch_migration_base
440
+ @@scratch_migration_base ||= begin
441
+ unless Object.const_defined?(:ScratchAddtoMigrationBase)
442
+ Object.const_set(:ScratchAddtoMigrationBase,
443
+ Class.new(ActiveRecord::Base) { self.abstract_class = true })
444
+ end
445
+ Object.const_get(:ScratchAddtoMigrationBase)
446
+ end
447
+ end
448
+
229
449
  def write_model(file, content)
230
450
  File.write(File.join(@destination_root, "app/models", file), content)
231
451
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # Covers the pending-migration gate: an attribute_list row whose column does
6
+ # not exist yet (the inline_forms_addto window between the model edit and
7
+ # `rails db:migrate`) must render a read-only placeholder and be skipped on
8
+ # write, instead of 500ing on the missing column. Gizmo's list references
9
+ # :pending_note, which has no column on the gizmos table (see
10
+ # test/integration_test_helper.rb and test/dummy/app/models/gizmo.rb).
11
+ class PendingMigrationGateTest < InlineFormsIntegrationTestCase
12
+ setup do
13
+ @gizmo = Gizmo.create!(name: "Gadget")
14
+ end
15
+
16
+ test "predicate flags a phantom column and only that" do
17
+ g = @gizmo
18
+ # phantom scalar column -> pending
19
+ assert InlineForms.attribute_pending_migration?(g, :pending_note, :text_field)
20
+ # real column -> not pending
21
+ refute InlineForms.attribute_pending_migration?(g, :name, :text_field)
22
+ # header is a label, never gated
23
+ refute InlineForms.attribute_pending_migration?(g, :section, :header)
24
+ # info bound to a real column
25
+ refute InlineForms.attribute_pending_migration?(g, :created_at, :info)
26
+ # relation dropdown backed by a missing foreign key -> pending
27
+ assert InlineForms.attribute_pending_migration?(g, :vendor, :dropdown)
28
+ # no-column elements are never gated
29
+ refute InlineForms.attribute_pending_migration?(g, :parts, :has_many)
30
+ refute InlineForms.attribute_pending_migration?(g, :body, :rich_text)
31
+ refute InlineForms.attribute_pending_migration?(g, :report, :pdf_link)
32
+ # exempt virtual-backed elements (column name != attribute)
33
+ refute InlineForms.attribute_pending_migration?(g, :amount, :money_field)
34
+ end
35
+
36
+ test "row show renders a pending placeholder and does not raise" do
37
+ frame = "gizmo_#{@gizmo.id}"
38
+ get gizmo_path(@gizmo, update: frame), headers: frame_headers(frame)
39
+
40
+ assert_response :success
41
+ assert_includes response.body, "pending migration"
42
+ # the real field still renders its own edit frame
43
+ assert_includes response.body, %(<turbo-frame id="gizmo_#{@gizmo.id}_name">)
44
+ # the phantom field is NOT rendered as an editable field frame
45
+ refute_includes response.body, %(<turbo-frame id="gizmo_#{@gizmo.id}_pending_note">)
46
+ end
47
+
48
+ test "new form renders a pending placeholder instead of an input" do
49
+ get new_gizmo_path(update: "gizmos_list"), headers: frame_headers("gizmos_list")
50
+
51
+ assert_response :success
52
+ assert_includes response.body, "pending migration"
53
+ refute_includes response.body, %(name="pending_note")
54
+ end
55
+
56
+ test "create skips the pending column and still saves" do
57
+ assert_difference -> { Gizmo.count }, +1 do
58
+ post gizmos_path(update: "gizmos_list"),
59
+ params: { name: "Fresh", pending_note: "ignored — no column yet" },
60
+ headers: frame_headers("gizmos_list")
61
+ end
62
+ assert_response :success
63
+ assert Gizmo.exists?(name: "Fresh")
64
+ end
65
+
66
+ test "forced update of a pending field is refused, not a 500" do
67
+ frame = "gizmo_#{@gizmo.id}_pending_note"
68
+ put gizmo_path(@gizmo, attribute: "pending_note", form_element: "text_field",
69
+ update: frame),
70
+ params: { pending_note: "x" }, headers: frame_headers(frame)
71
+
72
+ assert_response :unprocessable_entity
73
+ assert_includes response.body, "pending migration"
74
+ end
75
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # The batch pipeline (phases 1-3): drafting intents into a batch, freeze on
6
+ # submit, token-authenticated export + status callback, and import/replay
7
+ # via a recording executor (never mutates the dummy tree).
8
+ class SchemaBatchPipelineTest < InlineFormsIntegrationTestCase
9
+ def teardown
10
+ InlineFormsSchemaEdit.export_token = nil
11
+ super
12
+ end
13
+
14
+ def draft_widget_field!(attribute: "warehouse_note", label: "Warehouse note")
15
+ post inline_forms_schema_draft_path,
16
+ params: { model_name: "Widget", attribute: attribute,
17
+ form_element: "text_field", after: "name", label: label, locale: "en" }
18
+ end
19
+
20
+ # -- phase 1: drafting + freeze ------------------------------------------
21
+
22
+ test "drafting an intent creates a draft batch (the cart) and index shows it" do
23
+ draft_widget_field!
24
+ assert_response :redirect
25
+
26
+ batch = InlineForms::SchemaBatch.with_status(:draft).first
27
+ assert batch, "draft batch created"
28
+ assert_equal 1, batch.intents.count
29
+ intent = batch.intents.first
30
+ assert_equal %w[Widget warehouse_note text_field name], [
31
+ intent.target_model, intent.attr_name, intent.form_element, intent.after_attr
32
+ ]
33
+
34
+ get inline_forms_schema_index_path
35
+ assert_response :success
36
+ assert_includes response.body, "warehouse_note"
37
+ assert_includes response.body, "Submit batch"
38
+ end
39
+
40
+ test "invalid intents are rejected at drafting time" do
41
+ post inline_forms_schema_draft_path,
42
+ params: { model_name: "Widget", attribute: "name", form_element: "text_field" }
43
+ assert_response :success # renders :new with the error
44
+ assert_includes response.body, "already has a name column"
45
+ assert_equal 0, InlineForms::SchemaIntentRecord.count
46
+ end
47
+
48
+ test "submit freezes the batch: no more adds, edits or removals; digest sealed" do
49
+ draft_widget_field!
50
+ post inline_forms_schema_submit_batch_path
51
+ assert_response :redirect
52
+
53
+ batch = InlineForms::SchemaBatch.last
54
+ assert_equal "submitted", batch.status
55
+ assert batch.submitted_at
56
+ assert_match(/\Asha256:/, batch.content_digest)
57
+
58
+ intent = batch.intents.first
59
+ intent.label = "changed"
60
+ refute intent.save, "frozen intent must not save"
61
+ refute intent.destroy, "frozen intent must not be destroyed"
62
+
63
+ refute batch.intents.create(target_model: "Widget", attr_name: "late_one",
64
+ form_element: "text_field").persisted?,
65
+ "no new intents into a frozen batch"
66
+
67
+ batch.window_at = 2.days.from_now
68
+ refute batch.save, "frozen batch attributes must not change"
69
+
70
+ # A new draft goes into a NEW batch.
71
+ draft_widget_field!(attribute: "second_note")
72
+ assert_equal 2, InlineForms::SchemaBatch.count
73
+ end
74
+
75
+ test "submitting an empty batch is refused" do
76
+ post inline_forms_schema_submit_batch_path
77
+ assert_redirected_to inline_forms_schema_index_path
78
+ assert_equal 0, InlineForms::SchemaBatch.where(status: "submitted").count
79
+ end
80
+
81
+ test "status transitions follow the pipeline; illegal jumps raise" do
82
+ draft_widget_field!
83
+ batch = InlineForms::SchemaBatch.last.submit!
84
+
85
+ batch.transition!(:processing)
86
+ batch.transition!(:ready, git_sha: "abc123")
87
+ assert_equal "abc123", batch.git_sha
88
+ assert_raises(ArgumentError) { batch.transition!(:submitted) }
89
+ batch.transition!(:applied)
90
+ assert batch.applied_at
91
+ end
92
+
93
+ # -- phase 3: machine endpoints ------------------------------------------
94
+
95
+ test "export 404s without a configured token and 401s with a wrong one" do
96
+ draft_widget_field!
97
+ batch = InlineForms::SchemaBatch.last.submit!
98
+
99
+ get inline_forms_schema_export_path(batch)
100
+ assert_response :not_found
101
+
102
+ InlineFormsSchemaEdit.export_token = "sekret"
103
+ get inline_forms_schema_export_path(batch), headers: { "Authorization" => "Bearer wrong" }
104
+ assert_response :unauthorized
105
+ end
106
+
107
+ test "export returns the sealed payload; draft batches are refused" do
108
+ InlineFormsSchemaEdit.export_token = "sekret"
109
+ draft_widget_field!
110
+ batch = InlineForms::SchemaBatch.last
111
+
112
+ get inline_forms_schema_export_path(batch), headers: { "Authorization" => "Bearer sekret" }
113
+ assert_response :conflict
114
+
115
+ batch.submit!
116
+ get inline_forms_schema_export_path(batch), headers: { "Authorization" => "Bearer sekret" }
117
+ assert_response :success
118
+ payload = JSON.parse(response.body)
119
+ assert_equal 1, payload["format"]
120
+ assert_equal batch.content_digest, payload["digest"]
121
+ assert_equal "warehouse_note", payload["intents"].first["attribute"]
122
+ end
123
+
124
+ test "status callback drives the batch through the pipeline" do
125
+ InlineFormsSchemaEdit.export_token = "sekret"
126
+ draft_widget_field!
127
+ batch = InlineForms::SchemaBatch.last.submit!
128
+
129
+ post inline_forms_schema_batch_status_path(batch),
130
+ params: { status: "processing" }, headers: { "Authorization" => "Bearer sekret" }
131
+ assert_response :success
132
+ assert_equal "processing", batch.reload.status
133
+
134
+ post inline_forms_schema_batch_status_path(batch),
135
+ params: { status: "applied" }, headers: { "Authorization" => "Bearer sekret" }
136
+ assert_response :unprocessable_entity, "processing -> applied skips ready"
137
+
138
+ post inline_forms_schema_batch_status_path(batch),
139
+ params: { status: "failed", error: "test gate red" },
140
+ headers: { "Authorization" => "Bearer sekret" }
141
+ assert_equal "failed", batch.reload.status
142
+ assert_equal "test gate red", batch.error
143
+ end
144
+
145
+ # -- phase 2: export/import round trip -----------------------------------
146
+
147
+ test "import verifies the digest and replays intents through the executor" do
148
+ draft_widget_field!
149
+ batch = InlineForms::SchemaBatch.last.submit!
150
+
151
+ payload = InlineFormsSchemaEdit::BatchExport.payload(batch)
152
+
153
+ recorded = []
154
+ labels = []
155
+ import = InlineFormsSchemaEdit::BatchImport.new(
156
+ payload,
157
+ executor: ->(args, _root) { recorded << args },
158
+ label_writer: ->(**kwargs) { labels << kwargs; "/dev/null/labels.yml" }
159
+ )
160
+ result = import.apply!(destination_root: Dir.tmpdir)
161
+
162
+ assert_equal 1, result.applied
163
+ assert_equal [ "Widget", "warehouse_note:text_field", "--after=name" ], recorded.first
164
+ assert_equal "Warehouse note", labels.first[:label]
165
+ assert_includes result.plan.join("\n"), "inline_forms_addto Widget warehouse_note:text_field"
166
+ end
167
+
168
+ test "import rejects a tampered payload (digest mismatch) and stale intents" do
169
+ draft_widget_field!
170
+ batch = InlineForms::SchemaBatch.last.submit!
171
+ payload = InlineFormsSchemaEdit::BatchExport.payload(batch)
172
+
173
+ tampered = payload.deep_dup
174
+ tampered["intents"].first["attribute"] = "evil_column"
175
+ error = assert_raises(InlineFormsSchemaEdit::BatchImport::ImportError) do
176
+ InlineFormsSchemaEdit::BatchImport.new(tampered).verify!
177
+ end
178
+ assert_match(/digest mismatch/, error.message)
179
+
180
+ # A column that already exists in THIS checkout fails per-intent validation.
181
+ stale = InlineFormsSchemaEdit::BatchExport.payload(batch)
182
+ stale["intents"].first["attribute"] = "name"
183
+ stale["digest"] = "sha256:#{Digest::SHA256.hexdigest(JSON.generate(stale['intents']))}"
184
+ error = assert_raises(InlineFormsSchemaEdit::BatchImport::ImportError) do
185
+ InlineFormsSchemaEdit::BatchImport.new(stale).verify!
186
+ end
187
+ assert_match(/already has a name column/, error.message)
188
+ end
189
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # The dev-only schema-change GUI (InlineForms::SchemaController): new -> preview
6
+ # (no migration) -> apply (runs the addto generator, no db:migrate). Apply is
7
+ # exercised with a RECORDING executor so the suite never mutates the dummy tree.
8
+ class SchemaEditTest < InlineFormsIntegrationTestCase
9
+ def teardown
10
+ InlineForms::SchemaController.generator_executor = nil
11
+ InlineForms::SchemaController.label_writer = nil
12
+ super
13
+ end
14
+
15
+ test "new renders the form with supported elements" do
16
+ get inline_forms_schema_new_path
17
+ assert_response :success
18
+ assert_includes response.body, "Model"
19
+ assert_includes response.body, "Form element"
20
+ assert_includes response.body, "text_field"
21
+ assert_includes response.body, "Preview"
22
+ # data-turbo opt-out so the standalone utility navigates on POST
23
+ assert_includes response.body, %(data-turbo="false")
24
+ end
25
+
26
+ test "info is not offered and is rejected; header is not a scalar field" do
27
+ refute_includes InlineForms::SchemaPreview.supported_form_elements, :header
28
+ refute_includes InlineForms::SchemaPreview.supported_form_elements, :info
29
+
30
+ post inline_forms_schema_preview_path,
31
+ params: { model_name: "Widget", attribute: "meta", form_element: "info" }
32
+ assert_response :success
33
+ assert_includes response.body, "Unsupported form element"
34
+ end
35
+
36
+ test "header preview adds a no-column row (no migration, no widget)" do
37
+ post inline_forms_schema_preview_path,
38
+ params: { model_name: "Widget", attribute: "section_title",
39
+ form_element: "header", after: "name", label: "Section" }
40
+ assert_response :success
41
+ assert_includes response.body, "no column, no migration"
42
+ assert_includes response.body, "section_title (new)"
43
+ # header shows its label, not an input widget
44
+ assert_includes response.body, "Section"
45
+ refute_includes response.body, %(name="section_title")
46
+ refute_includes Widget.column_names, "section_title"
47
+ end
48
+
49
+ test "header apply invokes the generator without a column (no migration)" do
50
+ recorded = []
51
+ InlineForms::SchemaController.generator_executor = ->(args, _root) { recorded << args }
52
+
53
+ post inline_forms_schema_path,
54
+ params: { model_name: "Widget", attribute: "section_title", form_element: "header" }
55
+ assert_response :success
56
+ assert_includes response.body, "header"
57
+ assert_includes response.body, "no migration"
58
+ assert_equal [ "Widget", "section_title:header" ], recorded.first
59
+ end
60
+
61
+ test "a label is written through the injected label writer" do
62
+ recorded = []
63
+ InlineForms::SchemaController.generator_executor = ->(_args, _root) { }
64
+ InlineForms::SchemaController.label_writer = ->(**kw) { recorded << kw; "/tmp/inline_forms_labels.en.yml" }
65
+
66
+ post inline_forms_schema_path,
67
+ params: { model_name: "Widget", attribute: "section_title", form_element: "header",
68
+ label: "Section", locale: "en" }
69
+ assert_response :success
70
+ assert_includes response.body, "Wrote label"
71
+ assert_equal 1, recorded.size
72
+ assert_equal :section_title, recorded.first[:attribute]
73
+ assert_equal "Section", recorded.first[:label]
74
+ assert_equal "en", recorded.first[:locale]
75
+ end
76
+
77
+ test "preview shows the field at the right position with no migration" do
78
+ post inline_forms_schema_preview_path,
79
+ params: { model_name: "Widget", attribute: "internal_note",
80
+ form_element: "text_field", after: "name" }
81
+ assert_response :success
82
+ assert_includes response.body, "internal_note (new)"
83
+ # rendered live widget for the new field
84
+ assert_includes response.body, %(name="internal_note")
85
+ # placement: internal_note appears after name in the rendered order
86
+ assert response.body.index("name (new)").nil?
87
+ assert response.body.index(">name<") < response.body.index("internal_note (new)")
88
+
89
+ # No schema change happened.
90
+ refute_includes Widget.column_names, "internal_note"
91
+ assert_empty Dir.glob(Rails.root.join("db/migrate/*_inline_forms_add_to_*.rb"))
92
+ end
93
+
94
+ test "an unsupported (non-scalar) element is rejected with an error" do
95
+ post inline_forms_schema_preview_path,
96
+ params: { model_name: "Widget", attribute: "vendor", form_element: "dropdown" }
97
+ assert_response :success
98
+ assert_includes response.body, "Unsupported form element"
99
+ end
100
+
101
+ test "a value-bearing element previews with a placeholder values hash (no raise)" do
102
+ post inline_forms_schema_preview_path,
103
+ params: { model_name: "Widget", attribute: "rank",
104
+ form_element: "dropdown_with_values_with_stars", after: "name" }
105
+ assert_response :success
106
+ assert_includes response.body, "rank (new)"
107
+ assert_includes response.body, "placeholder values hash"
108
+ end
109
+
110
+ test "invalid input re-renders the form with an error" do
111
+ post inline_forms_schema_preview_path,
112
+ params: { model_name: "Widget", attribute: "Bad Name", form_element: "text_field" }
113
+ assert_response :success
114
+ assert_includes response.body, "lowercase letters"
115
+ end
116
+
117
+ test "apply invokes the generator (no db:migrate) via the injected executor" do
118
+ recorded = []
119
+ InlineForms::SchemaController.generator_executor = ->(args, _root) { recorded << args }
120
+
121
+ post inline_forms_schema_path,
122
+ params: { model_name: "Widget", attribute: "internal_note",
123
+ form_element: "text_field", after: "name" }
124
+ assert_response :success
125
+ assert_includes response.body, "Applied"
126
+ assert_includes response.body, "db:migrate" # the "you run this" instruction
127
+ assert_equal 1, recorded.size
128
+ assert_equal [ "Widget", "internal_note:text_field", "--after=name" ], recorded.first
129
+ # The generator never wrote a migration (recorder), and we never migrated.
130
+ refute_includes Widget.column_names, "internal_note"
131
+ end
132
+
133
+ test "reachable only outside production" do
134
+ original = Rails.env
135
+ Rails.env = ActiveSupport::StringInquirer.new("production")
136
+ get inline_forms_schema_new_path
137
+ assert_response :not_found
138
+ ensure
139
+ Rails.env = original
140
+ end
141
+ end