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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +149 -0
- data/Gemfile +3 -0
- data/README.rdoc +47 -0
- data/app/assets/stylesheets/inline_forms/inline_forms.scss +34 -0
- data/app/controllers/concerns/file_slots_concern.rb +211 -0
- data/app/controllers/inline_forms/file_trash_controller.rb +115 -0
- data/app/controllers/inline_forms_controller.rb +82 -16
- data/app/helpers/inline_forms_file_trash_helper.rb +131 -0
- data/app/helpers/inline_forms_helper.rb +5 -1
- data/app/models/concerns/inline_forms/stored_files.rb +350 -0
- data/app/models/inline_forms/current.rb +8 -0
- data/app/models/inline_forms/file_trash_sweep.rb +37 -0
- data/app/models/inline_forms/trashed_file.rb +205 -0
- data/app/views/inline_forms/_file_slot.html.erb +39 -0
- data/app/views/inline_forms/_header.html.erb +3 -0
- data/app/views/inline_forms/_model_top_bar.html.erb +3 -4
- data/app/views/inline_forms/_show.html.erb +6 -0
- data/app/views/inline_forms/_versions.html.erb +4 -4
- data/app/views/inline_forms/_versions_file_entry.html.erb +36 -0
- data/app/views/inline_forms/_versions_list.html.erb +11 -2
- data/app/views/inline_forms/file_field_frame.html.erb +22 -0
- data/app/views/inline_forms/file_trash/index.html.erb +112 -0
- data/app/views/inline_forms/file_trash_panel.html.erb +90 -0
- data/app/views/layouts/inline_forms.html.erb +5 -1
- data/lib/generators/inline_forms/file_trash_generator.rb +36 -0
- data/lib/generators/inline_forms/templates/create_inline_forms_file_trash.rb.erb +41 -0
- data/lib/generators/inline_forms_addto_generator.rb +47 -0
- data/lib/generators/inline_forms_generator.rb +19 -2
- data/lib/generators/templates/model.erb +4 -0
- data/lib/inline_forms/form_element_from_callee.rb +25 -0
- data/lib/inline_forms/form_elements/simple_file_field_helper.rb +30 -2
- data/lib/inline_forms/gem_files.rb +5 -0
- data/lib/inline_forms/version.rb +1 -1
- data/lib/inline_forms.rb +55 -0
- data/lib/locales/inline_forms.en.yml +87 -0
- data/lib/locales/inline_forms.nl.yml +87 -3
- data/lib/tasks/inline_forms_files.rake +61 -0
- data/test/dummy/app/controllers/application_controller.rb +5 -0
- data/test/dummy/app/controllers/documents_controller.rb +4 -0
- data/test/dummy/app/controllers/dossiers_controller.rb +4 -0
- data/test/dummy/app/models/ability.rb +15 -0
- data/test/dummy/app/models/document.rb +33 -0
- data/test/dummy/app/models/dossier.rb +38 -0
- data/test/dummy/config/application.rb +4 -3
- data/test/dummy/config/routes.rb +13 -0
- data/test/inline_forms_addto_generator_test.rb +47 -0
- data/test/inline_forms_generator_test.rb +24 -0
- data/test/integration/field_authorization_test.rb +131 -0
- data/test/integration/file_slots_test.rb +574 -0
- data/test/integration/layout_title_test.rb +56 -0
- data/test/integration/model_top_bar_test.rb +9 -0
- data/test/integration/revert_skipped_columns_test.rb +59 -0
- data/test/integration_test_helper.rb +62 -0
- metadata +34 -3
- data/.claude/settings.local.json +0 -9
- 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,
|
|
7
|
-
#
|
|
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.
|
data/test/dummy/config/routes.rb
CHANGED
|
@@ -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
|