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
|
@@ -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
|