inline_forms 8.1.47 → 8.1.53

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/CHANGELOG.md +149 -0
  4. data/Gemfile +3 -0
  5. data/README.rdoc +47 -0
  6. data/app/assets/stylesheets/inline_forms/inline_forms.scss +34 -0
  7. data/app/controllers/concerns/file_slots_concern.rb +211 -0
  8. data/app/controllers/inline_forms/file_trash_controller.rb +115 -0
  9. data/app/controllers/inline_forms_controller.rb +82 -16
  10. data/app/helpers/inline_forms_file_trash_helper.rb +131 -0
  11. data/app/helpers/inline_forms_helper.rb +5 -1
  12. data/app/models/concerns/inline_forms/stored_files.rb +350 -0
  13. data/app/models/inline_forms/current.rb +8 -0
  14. data/app/models/inline_forms/file_trash_sweep.rb +37 -0
  15. data/app/models/inline_forms/trashed_file.rb +205 -0
  16. data/app/views/inline_forms/_file_slot.html.erb +39 -0
  17. data/app/views/inline_forms/_header.html.erb +3 -0
  18. data/app/views/inline_forms/_model_top_bar.html.erb +3 -4
  19. data/app/views/inline_forms/_show.html.erb +6 -0
  20. data/app/views/inline_forms/_versions.html.erb +4 -4
  21. data/app/views/inline_forms/_versions_file_entry.html.erb +36 -0
  22. data/app/views/inline_forms/_versions_list.html.erb +11 -2
  23. data/app/views/inline_forms/file_field_frame.html.erb +22 -0
  24. data/app/views/inline_forms/file_trash/index.html.erb +112 -0
  25. data/app/views/inline_forms/file_trash_panel.html.erb +90 -0
  26. data/app/views/layouts/inline_forms.html.erb +5 -1
  27. data/lib/generators/inline_forms/file_trash_generator.rb +36 -0
  28. data/lib/generators/inline_forms/templates/create_inline_forms_file_trash.rb.erb +41 -0
  29. data/lib/generators/inline_forms_addto_generator.rb +47 -0
  30. data/lib/generators/inline_forms_generator.rb +19 -2
  31. data/lib/generators/templates/model.erb +4 -0
  32. data/lib/inline_forms/form_element_from_callee.rb +25 -0
  33. data/lib/inline_forms/form_elements/simple_file_field_helper.rb +30 -2
  34. data/lib/inline_forms/gem_files.rb +5 -0
  35. data/lib/inline_forms/version.rb +1 -1
  36. data/lib/inline_forms.rb +55 -0
  37. data/lib/locales/inline_forms.en.yml +87 -0
  38. data/lib/locales/inline_forms.nl.yml +87 -3
  39. data/lib/tasks/inline_forms_files.rake +61 -0
  40. data/test/dummy/app/controllers/application_controller.rb +5 -0
  41. data/test/dummy/app/controllers/documents_controller.rb +4 -0
  42. data/test/dummy/app/controllers/dossiers_controller.rb +4 -0
  43. data/test/dummy/app/models/ability.rb +15 -0
  44. data/test/dummy/app/models/document.rb +33 -0
  45. data/test/dummy/app/models/dossier.rb +38 -0
  46. data/test/dummy/config/application.rb +4 -3
  47. data/test/dummy/config/routes.rb +13 -0
  48. data/test/inline_forms_addto_generator_test.rb +47 -0
  49. data/test/inline_forms_generator_test.rb +24 -0
  50. data/test/integration/field_authorization_test.rb +131 -0
  51. data/test/integration/file_slots_test.rb +574 -0
  52. data/test/integration/layout_title_test.rb +56 -0
  53. data/test/integration/model_top_bar_test.rb +9 -0
  54. data/test/integration/revert_skipped_columns_test.rb +59 -0
  55. data/test/integration_test_helper.rb +62 -0
  56. metadata +34 -3
  57. data/.claude/settings.local.json +0 -9
  58. data/CLAUDE.md +0 -68
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Grants everything (the generated superadmin branch). A test narrows it by
4
+ # setting Ability.restrictions to a block of `cannot` rules, evaluated after
5
+ # the grant so they win; InlineFormsIntegrationTestCase resets it.
6
+ class Ability
7
+ include CanCan::Ability
8
+
9
+ cattr_accessor :restrictions
10
+
11
+ def initialize(_user)
12
+ can :manage, :all
13
+ instance_exec(&restrictions) if restrictions
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Declared file slots (InlineForms::StoredFiles), shaped like StProject's
4
+ # Client: two slots with the default column naming (`filename` -> data /
5
+ # content_type, `plan_filename` -> plan_data / plan_content_type). An
6
+ # ApplicationRecord subclass on purpose: has_paper_trail is inherited, so the
7
+ # slot columns' skip must be set on Document without leaking to Widget.
8
+ # See test/integration/file_slots_test.rb.
9
+ class Document < ApplicationRecord
10
+ include InlineForms::StoredFiles
11
+
12
+ inline_forms_file :filename
13
+ inline_forms_file :plan_filename
14
+
15
+ # StProject's Attachment/Incident/Zorgformulier validate the filename; a
16
+ # test switches this on to prove undo-of-destroy puts files back BEFORE save.
17
+ cattr_accessor :require_filename, default: false
18
+ validates :filename, presence: true, if: -> { self.class.require_filename }
19
+
20
+ validates :title, presence: true
21
+
22
+ def _presentation
23
+ "#{title}"
24
+ end
25
+
26
+ def inline_forms_attribute_list
27
+ @inline_forms_attribute_list ||= [
28
+ [ :title, :text_field ],
29
+ [ :filename, :simple_file_field ],
30
+ [ :plan_filename, :simple_file_field ]
31
+ ]
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Models StProject's Client/Attachment: file bytes stored in a binary column
4
+ # that is excluded from PaperTrail (`skip:`), filename kept versioned. Reverting
5
+ # an unrelated edit must not touch `data`. See
6
+ # test/integration/revert_skipped_columns_test.rb.
7
+ #
8
+ # Not an ApplicationRecord: that already calls has_paper_trail, which PaperTrail
9
+ # allows only once per hierarchy. StProject likewise declares it per model, so
10
+ # this repeats the rest of the ApplicationRecord host contract inline.
11
+ class Dossier < ActiveRecord::Base
12
+ include InlineForms::Searchable
13
+
14
+ has_paper_trail on: [ :create, :update, :destroy ], skip: [ :data ]
15
+
16
+ attr_writer :inline_forms_attribute_list
17
+
18
+ self.per_page = 7
19
+
20
+ scope :inline_forms_list, -> { order(:name, :id) }
21
+
22
+ validates :name, presence: true
23
+
24
+ def self.not_accessible_through_html?
25
+ false
26
+ end
27
+
28
+ def _presentation
29
+ "#{name}"
30
+ end
31
+
32
+ def inline_forms_attribute_list
33
+ @inline_forms_attribute_list ||= [
34
+ [ :name, :text_field ],
35
+ [ :data_filename, :info ]
36
+ ]
37
+ end
38
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  # Minimal host app for the engine's integration tests (test/integration/).
4
4
  # Boots the inline_forms engine against an in-memory SQLite database with the
5
- # host-side gems the engine expects (will_paginate, paper_trail, turbo-rails)
6
- # but without Devise, CanCanCan, Sprockets or ActionText: authorization is
7
- # optional in the engine (cancan_enabled? rescues NameError), tests drive the
5
+ # host-side gems the engine expects (will_paginate, paper_trail, turbo-rails,
6
+ # cancancan) but without Devise, Sprockets or ActionText. CanCanCan is on so
7
+ # load_and_authorize_resource and the attribute-level checks run; the dummy
8
+ # Ability grants everything unless a test narrows it. Tests drive the
8
9
  # UI through Turbo-Frame requests (the frame layout never renders the
9
10
  # current_user header), and the asset-precompile initializer skips hosts
10
11
  # without a pipeline.
@@ -25,6 +25,19 @@ Rails.application.routes.draw do
25
25
  get "list_versions", on: :member
26
26
  end
27
27
 
28
+ resources :dossiers do
29
+ post "revert", on: :member
30
+ get "list_versions", on: :member
31
+ end
32
+
33
+ resources :documents do
34
+ post "revert", on: :member
35
+ get "list_versions", on: :member
36
+ InlineForms.file_routes(self)
37
+ end
38
+
39
+ InlineForms.draw_file_trash_routes(self)
40
+
28
41
  get "stats", to: "stats#show"
29
42
 
30
43
  # Schema-change GUI (opt-in in real installs; routed here for the engine's
@@ -386,6 +386,53 @@ class InlineFormsAddtoGeneratorTest < Minitest::Test
386
386
  teardown_sqlite
387
387
  end
388
388
 
389
+ def test_simple_file_field_adds_a_declared_slot_columns_and_routes
390
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
391
+ mkdir_p("config")
392
+ File.write(File.join(@destination_root, "config/routes.rb"), <<~RUBY)
393
+ Rails.application.routes.draw do
394
+ resources :widgets do
395
+ post 'revert', :on => :member
396
+ get 'list_versions', :on => :member
397
+ end
398
+ end
399
+ RUBY
400
+
401
+ capture_io { run_generator("Widget", "manual:simple_file_field") }
402
+ capture_io { run_generator("Widget", "plan_filename:simple_file_field") }
403
+
404
+ model = read("app/models/widget.rb")
405
+ assert_equal(1, model.scan("include InlineForms::StoredFiles").size)
406
+ assert_includes(model, "inline_forms_file :manual")
407
+ assert_includes(model, "inline_forms_file :plan_filename")
408
+ assert_operator(model.index("include InlineForms::StoredFiles"), :<, model.index("inline_forms_file :manual"))
409
+ assert_operator(model.index("include InlineForms::StoredFiles"), :<, model.index("inline_forms_file :plan_filename"))
410
+ assert_includes(model, "[ :manual, :simple_file_field ]")
411
+ routes = read("config/routes.rb")
412
+ assert_equal(1, routes.scan("InlineForms.file_routes(self)").size)
413
+ assert_match(/resources :widgets do\n\s+InlineForms\.file_routes\(self\)/, routes)
414
+
415
+ migration_file = Dir.glob(File.join(@destination_root, "db/migrate/*_inline_forms_add_to_widgets_*.rb")).min
416
+ conn = apply_migration_to_memory_sqlite(migration_file, tables: %i[widgets])
417
+ %i[manual manual_data manual_content_type].each do |column|
418
+ assert(conn.column_exists?(:widgets, column), "#{column} should exist after migrate")
419
+ end
420
+ ensure
421
+ teardown_sqlite
422
+ end
423
+
424
+ def test_simple_file_field_without_a_resource_block_reminds_about_routes
425
+ write_model("widget.rb", GENERATOR_SHAPED_MODEL)
426
+ mkdir_p("config")
427
+ File.write(File.join(@destination_root, "config/routes.rb"), "Rails.application.routes.draw do\n MODELS.each { |m| resources m }\nend\n")
428
+
429
+ stdout, = capture_io { run_generator("Widget", "manual:simple_file_field") }
430
+
431
+ assert_includes(read("app/models/widget.rb"), "inline_forms_file :manual")
432
+ refute_includes(read("config/routes.rb"), "file_routes")
433
+ assert_includes(stdout, "InlineForms.file_routes(self)")
434
+ end
435
+
389
436
  private
390
437
 
391
438
  def run_generator(*args)
@@ -171,6 +171,30 @@ class InlineFormsGeneratorTest < Minitest::Test
171
171
  assert_includes(migration, "t.text :description")
172
172
  end
173
173
 
174
+ def test_simple_file_field_declares_a_file_slot_with_its_columns_and_routes
175
+ run_generator("Folder", "title:string", "plan_filename:simple_file_field", "_enabled:yes")
176
+
177
+ model = read("app/models/folder.rb")
178
+ routes = read("config/routes.rb")
179
+ migration = read_single_migration_for("folders")
180
+
181
+ assert_includes(model, "include InlineForms::StoredFiles")
182
+ assert_includes(model, "inline_forms_file :plan_filename")
183
+ assert_operator(model.index("include InlineForms::StoredFiles"), :<, model.index("inline_forms_file :plan_filename"))
184
+ assert_includes(model, "[ :plan_filename, :simple_file_field ]")
185
+ assert_includes(migration, "t.string :plan_filename")
186
+ assert_includes(migration, "t.binary :plan_data")
187
+ assert_includes(migration, "t.string :plan_content_type")
188
+ assert_includes(routes, "InlineForms.file_routes(self)")
189
+ end
190
+
191
+ def test_no_file_routes_without_a_file_field
192
+ run_generator("Thing", "name:string", "_enabled:yes")
193
+
194
+ refute_includes(read("config/routes.rb"), "file_routes")
195
+ refute_includes(read("app/models/thing.rb"), "StoredFiles")
196
+ end
197
+
174
198
  private
175
199
 
176
200
  def build_destination_skeleton!
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration_test_helper"
4
+
5
+ # Server-side guard on the single-field actions (edit / update / show with an
6
+ # attribute). The UI only hides the edit link for an attribute the user may
7
+ # not update; before 8.1.48 a hand-made request still went through, because
8
+ # load_and_authorize_resource authorizes the record only (CanCanCan passes
9
+ # attribute-scoped rules when no attribute is given) and `form_element` was
10
+ # dispatched as `send("#{form_element}_update")` unchecked.
11
+ class FieldAuthorizationTest < InlineFormsIntegrationTestCase
12
+ setup do
13
+ @widget = Widget.create!(name: "Alpha", accent: "#112233")
14
+ @frame = "widget_#{@widget.id}_accent"
15
+ end
16
+
17
+ def patch_field(attribute, form_element, params = {})
18
+ patch widget_path(@widget, attribute: attribute, form_element: form_element,
19
+ update: "widget_#{@widget.id}_#{attribute}"),
20
+ params: params, headers: frame_headers("widget_#{@widget.id}_#{attribute}")
21
+ end
22
+
23
+ test "the dummy runs with CanCanCan enabled" do
24
+ assert InlineFormsController.cancan_enabled?
25
+ end
26
+
27
+ test "crafted PATCH on an attribute-scoped `cannot :update` is refused" do
28
+ Ability.restrictions = -> { cannot :update, Widget, [ :accent ] }
29
+
30
+ patch_field "accent", "color_field", accent: "#ff0000"
31
+
32
+ assert_response :forbidden
33
+ assert_equal "#112233", @widget.reload.accent
34
+ end
35
+
36
+ test "other attributes of the same record stay updatable" do
37
+ Ability.restrictions = -> { cannot :update, Widget, [ :accent ] }
38
+
39
+ patch_field "name", "text_field", name: "Renamed"
40
+
41
+ assert_response :success
42
+ assert_equal "Renamed", @widget.reload.name
43
+ end
44
+
45
+ test "an attribute-scoped `can :update` only covers the listed attributes" do
46
+ Ability.restrictions = lambda do
47
+ cannot :update, Widget
48
+ can :update, Widget, [ :name ]
49
+ end
50
+
51
+ patch_field "accent", "color_field", accent: "#ff0000"
52
+ assert_response :forbidden
53
+ assert_equal "#112233", @widget.reload.accent
54
+
55
+ patch_field "name", "text_field", name: "Renamed"
56
+ assert_response :success
57
+ assert_equal "Renamed", @widget.reload.name
58
+ end
59
+
60
+ test "edit of a `cannot :update` attribute is refused" do
61
+ Ability.restrictions = -> { cannot :update, Widget, [ :accent ] }
62
+
63
+ get edit_widget_path(@widget, attribute: "accent", form_element: "color_field", update: @frame),
64
+ headers: frame_headers(@frame)
65
+
66
+ assert_response :forbidden
67
+ end
68
+
69
+ test "single-field show of a `cannot :read` attribute is refused" do
70
+ Ability.restrictions = -> { cannot :read, Widget, [ :accent ] }
71
+
72
+ get widget_path(@widget, attribute: "accent", form_element: "color_field", update: @frame),
73
+ headers: frame_headers(@frame)
74
+
75
+ assert_response :forbidden
76
+ end
77
+
78
+ test "a form_element that does not match the attribute's row is rejected" do
79
+ patch_field "accent", "text_field", accent: "not a color"
80
+
81
+ assert_response :bad_request
82
+ assert_equal "#112233", @widget.reload.accent
83
+ end
84
+
85
+ test "an unknown form_element is rejected before it is dispatched" do
86
+ patch_field "name", "nonexistent", name: "Renamed"
87
+
88
+ assert_response :bad_request
89
+ assert_equal "Alpha", @widget.reload.name
90
+ end
91
+
92
+ test "an attribute outside the attribute list is rejected" do
93
+ patch_field "kind_id", "text_field", kind_id: "42"
94
+
95
+ assert_response :bad_request
96
+ assert_nil @widget.reload.kind_id
97
+ end
98
+
99
+ test "missing attribute or form_element is rejected" do
100
+ patch widget_path(@widget, update: @frame), params: { name: "x" }, headers: frame_headers(@frame)
101
+ assert_response :bad_request
102
+
103
+ get edit_widget_path(@widget, attribute: "name", update: @frame), headers: frame_headers(@frame)
104
+ assert_response :bad_request
105
+ end
106
+
107
+ test "show cannot be used to send an arbitrary method to the record" do
108
+ get widget_path(@widget, attribute: "destroy", form_element: "has_one", update: @frame),
109
+ headers: frame_headers(@frame)
110
+
111
+ assert_response :bad_request
112
+ assert Widget.exists?(@widget.id)
113
+ end
114
+
115
+ test "a delegating element's link name (plain_text_area -> plain_text) is accepted" do
116
+ patch_field "notes", "plain_text", notes: "Long enough notes"
117
+
118
+ assert_response :success
119
+ assert_equal "Long enough notes", @widget.reload.notes
120
+ end
121
+
122
+ test "attribute_list_row_for honours the declared element and its delegate only" do
123
+ list = Widget.new.inline_forms_attribute_list
124
+
125
+ assert_equal [ :notes, :plain_text_area ], InlineForms.attribute_list_row_for(list, "notes", "plain_text_area")
126
+ assert_equal [ :notes, :plain_text_area ], InlineForms.attribute_list_row_for(list, "notes", "plain_text")
127
+ assert_nil InlineForms.attribute_list_row_for(list, "notes", "rich_text")
128
+ assert_nil InlineForms.attribute_list_row_for(list, "name", nil)
129
+ assert_nil InlineForms.attribute_list_row_for(list, nil, "text_field")
130
+ end
131
+ end