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,37 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ # One row per run of the retention sweep (`rake inline_forms:file_trash:
4
+ # purge_expired`): the heartbeat the global trash page shows, so a cron job
5
+ # that silently stopped is visible to a human.
6
+ class InlineForms::FileTrashSweep < ActiveRecord::Base
7
+ self.table_name = "inline_forms_file_trash_sweeps"
8
+
9
+ # A sweep older than this is reported as overdue (daily cron + slack).
10
+ OVERDUE_AFTER = 48.hours
11
+
12
+ def self.last_run
13
+ order(ran_at: :desc, id: :desc).first
14
+ end
15
+
16
+ def self.overdue?
17
+ return false if InlineForms.file_trash_retention.nil?
18
+
19
+ last = last_run
20
+ last.nil? || last.ran_at < OVERDUE_AFTER.ago
21
+ end
22
+
23
+ # Runs the sweep and records it. Re-raises after recording a failure, so
24
+ # the rake task exits non-zero and cron mails it.
25
+ def self.run!
26
+ purged = InlineForms::TrashedFile.purge_expired!
27
+ create!(ran_at: Time.current, purged_count: purged)
28
+ purged
29
+ rescue StandardError => e
30
+ begin
31
+ create!(ran_at: Time.current, purged_count: 0, error: "#{e.class}: #{e.message}".truncate(1000))
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ raise
36
+ end
37
+ end
@@ -0,0 +1,205 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ require "digest"
4
+
5
+ # One row per file event on a declared file slot (InlineForms::StoredFiles).
6
+ #
7
+ # Trash rows (reason removed / replaced / record_destroyed / orphaned) hold the
8
+ # bytes of a file that left its slot, so it can be restored (undo) or purged
9
+ # (bytes deleted for good, metadata kept as a tombstone = history). Event rows
10
+ # (reason uploaded) never hold bytes; they only record who put a file in a
11
+ # slot, because the slot columns are kept out of PaperTrail.
12
+ #
13
+ # States of a trash row:
14
+ # trashed data present, not restored, not purged, not expired
15
+ # restored bytes went back to the record; data NULLed, row kept as history
16
+ # purged data NULLed by a user or the retention sweep; tombstone kept
17
+ # expired purge_after passed but the sweep has not run yet: treated as
18
+ # purged by every read path (no download, no restore)
19
+ #
20
+ # Every state change is a conditional UPDATE (WHERE still trashed) whose row
21
+ # count is checked, so a restore racing a purge, a replayed POST or a second
22
+ # click never acts twice. Lock order everywhere: the owner record's row first,
23
+ # then this table's row.
24
+ #
25
+ # Lists must never load the bytes: use +without_data+.
26
+ class InlineForms::TrashedFile < ActiveRecord::Base
27
+ self.table_name = "inline_forms_trashed_files"
28
+
29
+ class NotRestorable < StandardError; end
30
+ class RecordGone < NotRestorable; end
31
+
32
+ TRASH_REASONS = %w[removed replaced record_destroyed orphaned].freeze
33
+ EVENT_REASONS = %w[uploaded].freeze
34
+ REASONS = (TRASH_REASONS + EVENT_REASONS).freeze
35
+
36
+ validates :record_type, :record_id, :attribute_name, :reason, presence: true
37
+ validates :reason, inclusion: { in: REASONS }
38
+
39
+ scope :without_data, -> { select(column_names - [ "data" ]) }
40
+ scope :trash_entries, -> { where(reason: TRASH_REASONS) }
41
+ scope :events, -> { where(reason: EVENT_REASONS) }
42
+ scope :for_record, ->(record) { where(record_type: record.class.base_class.name, record_id: record.id) }
43
+ scope :for_slot, ->(record, attribute) { for_record(record).where(attribute_name: attribute.to_s) }
44
+ scope :unexpired, -> { where("purge_after IS NULL OR purge_after > ?", Time.current) }
45
+ scope :expired, -> { where("purge_after IS NOT NULL AND purge_after <= ?", Time.current) }
46
+ scope :still_trashed, -> { trash_entries.where(restored_at: nil, purged_at: nil) }
47
+ scope :restorable, -> { still_trashed.unexpired }
48
+ scope :newest_first, -> { order(trashed_at: :desc, id: :desc) }
49
+
50
+ # ---- writing --------------------------------------------------------------
51
+
52
+ # Moves +data+ (the bytes that just left +attribute+ on +record+) into the
53
+ # trash. Called from inside the record's transaction.
54
+ def self.trash!(record:, attribute:, data:, filename:, content_type:, reason:, by: nil, at: Time.current)
55
+ retention = InlineForms.file_trash_retention
56
+ create!(
57
+ record_type: record.class.base_class.name,
58
+ record_id: record.id,
59
+ attribute_name: attribute.to_s,
60
+ filename: filename,
61
+ content_type: content_type,
62
+ byte_size: data&.bytesize,
63
+ checksum: data && Digest::SHA256.hexdigest(data),
64
+ data: data,
65
+ reason: reason.to_s,
66
+ trashed_by_id: by,
67
+ trashed_at: at,
68
+ purge_after: retention && at + retention
69
+ )
70
+ end
71
+
72
+ # Metadata-only "a file was put in this slot" event.
73
+ def self.record_upload!(record:, attribute:, filename:, content_type:, byte_size:, checksum:, by: nil, at: Time.current)
74
+ create!(
75
+ record_type: record.class.base_class.name,
76
+ record_id: record.id,
77
+ attribute_name: attribute.to_s,
78
+ filename: filename,
79
+ content_type: content_type,
80
+ byte_size: byte_size,
81
+ checksum: checksum,
82
+ reason: "uploaded",
83
+ trashed_by_id: by,
84
+ trashed_at: at
85
+ )
86
+ end
87
+
88
+ # Purges every trash row whose retention has passed. Returns the number
89
+ # purged. Idempotent; safe to run while users work.
90
+ def self.purge_expired!
91
+ ids = still_trashed.expired.pluck(:id)
92
+ ids.count { |id| find_by(id: id)&.purge!(by: nil) }
93
+ end
94
+
95
+ # ---- state ----------------------------------------------------------------
96
+
97
+ def trash_entry?
98
+ TRASH_REASONS.include?(reason)
99
+ end
100
+
101
+ def event?
102
+ EVENT_REASONS.include?(reason)
103
+ end
104
+
105
+ def restored?
106
+ restored_at.present?
107
+ end
108
+
109
+ def purged?
110
+ purged_at.present?
111
+ end
112
+
113
+ def expired?
114
+ purge_after.present? && purge_after <= Time.current
115
+ end
116
+
117
+ # Still holds bytes that may be downloaded, restored or purged.
118
+ def trashed?
119
+ trash_entry? && !restored? && !purged? && !expired?
120
+ end
121
+
122
+ # May still be purged: trashed, or expired but not yet swept.
123
+ def purgeable?
124
+ trash_entry? && !restored? && !purged?
125
+ end
126
+
127
+ # :trashed, :restored, :purged, :expired or :event
128
+ def state
129
+ return :event if event?
130
+ return :restored if restored?
131
+ return :purged if purged?
132
+ return :expired if expired?
133
+
134
+ :trashed
135
+ end
136
+
137
+ # The record this file belongs to, or nil when it was destroyed (or its
138
+ # class is gone). Never constantizes anything but a stored record_type.
139
+ #
140
+ # Loaded without the slots' byte columns (it is used for authorization and
141
+ # labels; reading a file goes through the slot API, which queries fresh).
142
+ def owner
143
+ klass = owner_class
144
+ return nil unless klass
145
+
146
+ scope = klass.unscoped
147
+ if klass.respond_to?(:inline_forms_file_slots)
148
+ blob_columns = klass.inline_forms_file_slots.values.map { |slot| slot[:data] }
149
+ scope = scope.select(klass.column_names - blob_columns)
150
+ end
151
+ scope.find_by(klass.primary_key => record_id)
152
+ end
153
+
154
+ def owner_class
155
+ klass = record_type.safe_constantize
156
+ klass.is_a?(Class) && klass < ActiveRecord::Base ? klass : nil
157
+ end
158
+
159
+ # The bytes, loaded on demand (lists select without them).
160
+ def file_data
161
+ self.class.where(id: id).pick(:data)
162
+ end
163
+
164
+ # ---- actions --------------------------------------------------------------
165
+
166
+ # Permanently deletes the bytes. Returns true when this call purged the row,
167
+ # false when it was already restored or purged (stale page, replayed POST).
168
+ def purge!(by:)
169
+ changes = { data: nil, purged_at: Time.current, purged_by_id: by, updated_at: Time.current }
170
+ if InlineForms.file_trash_redact_on_purge
171
+ changes[:filename] = nil
172
+ changes[:redacted] = true
173
+ end
174
+ count = self.class.where(id: id).still_trashed.update_all(changes)
175
+ reload if count == 1
176
+ count == 1
177
+ end
178
+
179
+ # Puts the bytes back into the slot. If the slot holds a file now (someone
180
+ # uploaded since), that file goes to the trash first (reason replaced) —
181
+ # never a silent overwrite. Raises RecordGone when the record was destroyed
182
+ # (restore the record instead; its files come back with it) and
183
+ # NotRestorable when the entry is no longer trashed.
184
+ def restore!(by:)
185
+ record_class = owner_class
186
+ raise RecordGone, "the record was deleted" unless record_class
187
+
188
+ record_class.transaction do
189
+ record = record_class.unscoped.lock.find_by(record_class.primary_key => record_id)
190
+ raise RecordGone, "the record was deleted" unless record
191
+
192
+ payload = self.class.lock.where(id: id).restorable.pick(:data, :filename, :content_type)
193
+ raise NotRestorable, "this file can no longer be restored" unless payload
194
+
195
+ now = Time.current
196
+ count = self.class.where(id: id).restorable.update_all(data: nil, restored_at: now, restored_by_id: by, updated_at: now)
197
+ raise NotRestorable, "this file can no longer be restored" unless count == 1
198
+
199
+ data, filename, content_type = payload
200
+ record.inline_forms_put_file!(attribute_name, data: data, filename: filename, content_type: content_type, by: by)
201
+ reload
202
+ record
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,39 @@
1
+ <%# A declared file slot (InlineForms::StoredFiles) inside its field frame.
2
+ Present: download link + replace (pencil) / remove (trash) by permission.
3
+ Empty: the usual plus link to upload, and, when the slot's last file is
4
+ still in the trash, an undo link to put it back. -%>
5
+ <% frame_id = inline_forms_file_field_frame_id(object, attribute) -%>
6
+ <% filename = object[attribute.to_s] -%>
7
+ <% slots_param = inline_forms_file_trash_slots(object).join(",") -%>
8
+ <span class="inline_forms-file">
9
+ <% if object.inline_forms_file_present?(attribute) %>
10
+ <%= link_to filename,
11
+ polymorphic_path(object, action: :download_file, attribute: attribute),
12
+ class: "inline_forms-file-name", data: { turbo: false } -%>
13
+ <span class="inline_forms-file-actions">
14
+ <% if inline_forms_file_can?(:update, object, attribute) && inline_forms_file_can?(:replace_file, object, attribute) %>
15
+ <%= inline_forms_file_icon_link "fi-pencil", t("inline_forms.files.replace"),
16
+ edit_polymorphic_path(object, attribute: attribute.to_s, form_element: "simple_file_field", update: frame_id),
17
+ data: { turbo: true, turbo_frame: (@inline_forms_turbo_field ? "_self" : frame_id) } -%>
18
+ <% end %>
19
+ <% if inline_forms_file_can?(:remove_file, object, attribute) %>
20
+ <% days = inline_forms_file_retention_days -%>
21
+ <% confirm = days ? t("inline_forms.files.confirm_remove", filename: filename, count: days) : t("inline_forms.files.confirm_remove_no_expiry", filename: filename) -%>
22
+ <%= inline_forms_file_icon_link "fi-trash", t("inline_forms.files.remove"),
23
+ polymorphic_path(object, action: :remove_file, attribute: attribute, update: frame_id, slots: slots_param),
24
+ inline_forms_file_post_data(frame_id, confirm: confirm) -%>
25
+ <% end %>
26
+ </span>
27
+ <% else %>
28
+ <%= link_to_inline_edit object, attribute, "<i class='fi-plus'></i>".html_safe, from_callee: :simple_file_field_show -%>
29
+ <%# Right after a remove the notice below carries the undo; don't offer it twice. -%>
30
+ <% if @file_notice != :removed && inline_forms_file_can?(:restore_file, object, attribute) && inline_forms_file_can?(:update, object, attribute) %>
31
+ <% entry = inline_forms_restorable_file_entries(object, [ attribute.to_s ])[attribute.to_s] -%>
32
+ <% if entry %>
33
+ <%= link_to t("inline_forms.files.restore_previous", filename: inline_forms_file_entry_name(entry)),
34
+ polymorphic_path(object, action: :restore_file, attribute: attribute, trash_id: entry.id, update: frame_id, slots: slots_param),
35
+ inline_forms_file_post_data(frame_id).deep_merge(class: "inline_forms-file-undo") -%>
36
+ <% end %>
37
+ <% end %>
38
+ <% end %>
39
+ </span>
@@ -29,6 +29,9 @@
29
29
  <li>
30
30
  <a href="#"><%= current_user.name %></a>
31
31
  <ul class="menu">
32
+ <% if (trash_link = link_to_inline_forms_file_trash) %>
33
+ <li><%= trash_link %></li>
34
+ <% end %>
32
35
  <li>
33
36
  <%= link_to( t("common.logout"), destroy_user_session_path, :method => :delete) if current_user %>
34
37
  </li>
@@ -9,10 +9,9 @@
9
9
  </a>
10
10
  </h1>
11
11
  </li>
12
- <% if respond_to?(:inline_forms_schema_new_path) %>
13
- <li class="menu-text">
14
- <%= link_to "+ field", inline_forms_schema_new_path, data: { turbo: false }, title: "Add a field to a model (dev only)" %>
15
- </li>
12
+ <%# Hook for app-specific items next to the title (none by default). %>
13
+ <% if lookup_context.exists?("inline_forms/model_top_bar_left", [], true) %>
14
+ <%= render "inline_forms/model_top_bar_left" %>
16
15
  <% end %>
17
16
  </ul>
18
17
  </div>
@@ -89,6 +89,12 @@
89
89
  <% end %>
90
90
  <% end %>
91
91
  <% end %>
92
+ <% file_trash_slots = inline_forms_file_trash_slots(@object, attributes) -%>
93
+ <% if file_trash_slots.any? && inline_forms_file_can?(:read_file_trash, @object) %>
94
+ <%= render template: "inline_forms/file_trash_panel",
95
+ locals: { object: @object, update_span: inline_forms_file_trash_turbo_frame_id(@object),
96
+ slots: file_trash_slots, open: false } %>
97
+ <% end %>
92
98
  <% if cancan_disabled? || can?(:list_versions, @object) %>
93
99
  <% css_class_id = inline_forms_versions_turbo_frame_id(@object) -%>
94
100
  <turbo-frame id="<%= css_class_id -%>">
@@ -1,13 +1,13 @@
1
1
  <% object = local_assigns[:object] || @object -%>
2
2
  <% css_class_id = inline_forms_versions_turbo_frame_id(object) -%>
3
- <%# Count the same merged set the open list does (`inline_forms_versions_for`
4
- sums the parent's PaperTrail versions and the versions of any associated
5
- ActionText::RichText records). Using `object.versions.length` here meant
3
+ <%# Count the same merged set the open list does (`inline_forms_history_for`:
4
+ the parent's PaperTrail versions, the versions of any associated
5
+ ActionText::RichText records, and file events of declared file slots). Using `object.versions.length` here meant
6
6
  the closed header showed N (parent only) and the open header showed N+M
7
7
  (parent + rich_text), flickering as the user toggled the panel. -%>
8
8
  <div class="row form_element_header associated_auto_header callout">
9
9
  <div class='medium-11 large-11 column' >
10
- <%= "Versions (#{inline_forms_versions_for(object).length})" %>
10
+ <%= "Versions (#{inline_forms_history_for(object).length})" %>
11
11
  </div>
12
12
  <div class='medium-1 large-1 column'>
13
13
  <%= link_to_versions_list(
@@ -0,0 +1,36 @@
1
+ <%# One file event in the versions list (kind :file, from the trash table):
2
+ uploaded / removed / replaced / record_destroyed / restored / purged.
3
+ Its restore link is "restore file" (restore_file, not revert), offered
4
+ only on the slot's newest still-restorable trash entry. -%>
5
+ <% row = entry[:entry] -%>
6
+ <% attribute = row.attribute_name -%>
7
+ <% field_frame = inline_forms_file_field_frame_id(object, attribute) -%>
8
+ <div class="row <%= cycle('odd', 'even') %> inline_forms-versions-file">
9
+ <div class="small-1 column">
10
+ <% if restorable[attribute]&.id == row.id && entry[:file_event] == row.reason.to_sym &&
11
+ inline_forms_file_can?(:restore_file, object, attribute) &&
12
+ inline_forms_file_can?(object.inline_forms_file_present?(attribute) ? :replace_file : :update, object, attribute) %>
13
+ <%= link_to t("inline_forms.files.restore_file"),
14
+ polymorphic_path(object, action: :restore_file, attribute: attribute, trash_id: row.id,
15
+ update: field_frame, slots: inline_forms_file_trash_slots(object).join(",")),
16
+ inline_forms_file_post_data(field_frame,
17
+ confirm: (object.inline_forms_file_present?(attribute) ? t("inline_forms.files.confirm_restore_swap", current: object[attribute], filename: inline_forms_file_entry_name(row)) : nil)) %>
18
+ <% else %>
19
+ &nbsp;
20
+ <% end %>
21
+ </div>
22
+ <div class="small-1 column">
23
+ <%= t("inline_forms.files.event.#{entry[:file_event]}") %>
24
+ </div>
25
+ <div class="small-2 column">
26
+ <%= entry[:at] %>
27
+ </div>
28
+ <div class="small-2 column">
29
+ <%= inline_forms_file_user_name(entry[:by]) %>
30
+ </div>
31
+ <div class="small-6 column">
32
+ <strong><%= object.class.human_attribute_name(attribute) %></strong>:
33
+ <%= inline_forms_file_entry_name(row) %>
34
+ <span class="inline_forms-file-meta"><%= inline_forms_file_size(row.byte_size) %></span>
35
+ </div>
36
+ </div>
@@ -1,6 +1,7 @@
1
1
  <% path_to_object = @object.class.to_s.underscore + '_path' %>
2
2
  <% css_class_id = inline_forms_versions_turbo_frame_id(@object) -%>
3
- <% entries = inline_forms_versions_for(@object) -%>
3
+ <% entries = inline_forms_history_for(@object) -%>
4
+ <% restorable_files = inline_forms_restorable_file_entries(@object, entries.filter_map { |e| e[:entry]&.attribute_name }.uniq) -%>
4
5
  <div class="row form_element_header associated_auto_header callout">
5
6
  <div class='medium-11 large-11 column' >
6
7
  <%= "Versions (#{entries.length})" %>
@@ -33,14 +34,22 @@
33
34
  <div class="small-1 column">
34
35
  <strong>Event</strong>
35
36
  </div>
37
+ <div class="small-2 column">
38
+ <strong>When</strong>
39
+ </div>
36
40
  <div class="small-2 column">
37
41
  <strong>Done by</strong>
38
42
  </div>
39
- <div class="small-8 column">
43
+ <div class="small-6 column">
40
44
  <strong>Changeset</strong>
41
45
  </div>
42
46
  </div>
43
47
  <% entries.reverse.each do |entry| %>
48
+ <% if entry[:kind] == :file %>
49
+ <%= render partial: "inline_forms/versions_file_entry",
50
+ locals: { entry: entry, object: @object, restorable: restorable_files } %>
51
+ <% next %>
52
+ <% end %>
44
53
  <% version = entry[:version] -%>
45
54
  <% rich_text_name = entry[:rich_text_name] -%>
46
55
  <div class="row <%= cycle('odd', 'even') %>">
@@ -0,0 +1,22 @@
1
+ <%# A file slot's field frame after a file action (FileSlotsConcern), with a
2
+ one-line status notice; after a remove the notice carries the undo. -%>
3
+ <% frame_id = local_assigns[:frame_id] || @update_span -%>
4
+ <turbo-frame id="<%= frame_id %>">
5
+ <%= simple_file_field_show(@object, @attribute) %>
6
+ <% if @file_notice %>
7
+ <p class="inline_forms-file-notice" role="status">
8
+ <% if @file_notice == :removed %>
9
+ <%= t("inline_forms.files.notice_removed", filename: inline_forms_file_entry_name(@file_entry)) %>
10
+ <% if @file_entry&.trashed? && inline_forms_file_can?(:restore_file, @object, @attribute) && inline_forms_file_can?(:update, @object, @attribute) %>
11
+ &middot;
12
+ <%= link_to t("inline_forms.files.undo"),
13
+ polymorphic_path(@object, action: :restore_file, attribute: @attribute, trash_id: @file_entry.id,
14
+ update: frame_id, slots: inline_forms_file_trash_slots_param),
15
+ inline_forms_file_post_data(frame_id) %>
16
+ <% end %>
17
+ <% else %>
18
+ <%= t("inline_forms.files.notice_#{@file_notice}") %>
19
+ <% end %>
20
+ </p>
21
+ <% end %>
22
+ </turbo-frame>
@@ -0,0 +1,112 @@
1
+ <%# Global file trash (InlineForms::FileTrashController#index). -%>
2
+ <div class="row inline_forms-file-trash-page">
3
+ <div class="small-12 column">
4
+ <h2><%= t("inline_forms.files.global.title") %></h2>
5
+
6
+ <% if @sweep_overdue %>
7
+ <div class="callout warning" role="alert">
8
+ <%= @last_sweep ? t("inline_forms.files.global.sweep_overdue") : t("inline_forms.files.global.sweep_never") %>
9
+ </div>
10
+ <% end %>
11
+ <p class="inline_forms-file-meta">
12
+ <% if @last_sweep.nil? %>
13
+ <%# (reported above when retention is on) %>
14
+ <% elsif @last_sweep.error.present? %>
15
+ <%= t("inline_forms.files.global.sweep_failed", error: @last_sweep.error) %>
16
+ <% else %>
17
+ <%= t("inline_forms.files.global.sweep_last", at: l(@last_sweep.ran_at, format: :short), count: @last_sweep.purged_count) %>
18
+ <% end %>
19
+ </p>
20
+
21
+ <%= form_with url: inline_forms_file_trash_path, method: :get, local: true, class: "inline_forms-file-trash-filter" do %>
22
+ <div class="row">
23
+ <div class="small-12 medium-4 column">
24
+ <label><%= t("inline_forms.files.global.model") %>
25
+ <%= select_tag :model, options_for_select([ [ t("inline_forms.files.global.all"), "" ] ] + @models.map { |m| [ (m.safe_constantize&.model_name&.human || m), m ] }, @model) %>
26
+ </label>
27
+ </div>
28
+ <div class="small-12 medium-4 column">
29
+ <label><%= t("inline_forms.files.global.state") %>
30
+ <%= select_tag :state, options_for_select(InlineForms::FileTrashController::STATES.map { |s| [ (s == "all" ? t("inline_forms.files.global.all") : t("inline_forms.files.global.states.#{s}")), s ] }, @state) %>
31
+ </label>
32
+ </div>
33
+ <div class="small-12 medium-4 column">
34
+ <label>&nbsp;<%= submit_tag t("inline_forms.files.global.filter"), class: "button", name: nil %></label>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+
39
+ <% if @entries.empty? %>
40
+ <p><%= t("inline_forms.files.global.empty") %></p>
41
+ <% else %>
42
+ <% purgeable = @entries.any?(&:purgeable?) -%>
43
+ <%= form_with url: inline_forms_file_trash_purge_path(model: @model, state: @state, page: params[:page]), method: :post, local: true do %>
44
+ <table class="inline_forms-file-trash-table">
45
+ <thead>
46
+ <tr>
47
+ <% if purgeable %><th></th><% end %>
48
+ <th><%= t("inline_forms.files.global.record") %></th>
49
+ <th><%= t("inline_forms.files.global.slot") %></th>
50
+ <th><%= t("inline_forms.files.global.file") %></th>
51
+ <th><%= t("inline_forms.files.global.trashed") %></th>
52
+ <th><%= t("inline_forms.files.global.state") %></th>
53
+ <th><%= t("inline_forms.files.global.actions") %></th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ <% @entries.each do |entry| %>
58
+ <% klass = entry.owner_class -%>
59
+ <% record = entry.owner -%>
60
+ <tr class="<%= 'inline_forms-file-trash-history' unless entry.trashed? %>">
61
+ <% if purgeable %>
62
+ <td>
63
+ <% if entry.purgeable? %>
64
+ <%= check_box_tag "ids[]", entry.id, false, id: "trash_entry_#{entry.id}", "aria-label": inline_forms_file_entry_name(entry) %>
65
+ <% end %>
66
+ </td>
67
+ <% end %>
68
+ <td>
69
+ <% if record %>
70
+ <%= "#{klass.model_name.human} ##{entry.record_id}" %>
71
+ <% if record.respond_to?(:_presentation) %><span class="inline_forms-file-meta"><%= record._presentation %></span><% end %>
72
+ <% else %>
73
+ <%= t("inline_forms.files.global.record_gone", model: (klass&.model_name&.human || entry.record_type), id: entry.record_id) %>
74
+ <% end %>
75
+ </td>
76
+ <td><%= klass ? klass.human_attribute_name(entry.attribute_name) : entry.attribute_name %></td>
77
+ <td>
78
+ <span class="inline_forms-file-name"><%= inline_forms_file_entry_name(entry) %></span>
79
+ <span class="inline_forms-file-meta"><%= inline_forms_file_size(entry.byte_size) %> &middot; <%= t("inline_forms.files.reason.#{entry.reason}") %></span>
80
+ </td>
81
+ <td><%= t("inline_forms.files.by_on", user: inline_forms_file_user_name(entry.trashed_by_id), at: l(entry.trashed_at, format: :short)) %></td>
82
+ <td>
83
+ <% if entry.state == :restored %><%= t("inline_forms.files.state_restored", user: inline_forms_file_user_name(entry.restored_by_id), at: l(entry.restored_at, format: :short)) %>
84
+ <% elsif entry.state == :purged %><%= t("inline_forms.files.state_purged", user: inline_forms_file_user_name(entry.purged_by_id), at: l(entry.purged_at, format: :short)) %>
85
+ <% elsif entry.state == :expired %><%= t("inline_forms.files.state_expired") %>
86
+ <% else %>
87
+ <% if entry.purge_after %><%= t("inline_forms.files.expires_in", count: ((entry.purge_after - Time.current) / 1.day).ceil, date: l(entry.purge_after.to_date)) %><% else %><%= t("inline_forms.files.global.states.trashed") %><% end %>
88
+ <% end %>
89
+ </td>
90
+ <td class="inline_forms-file-actions">
91
+ <% if entry.trashed? %>
92
+ <%= link_to t("inline_forms.files.download"), inline_forms_file_trash_download_path(entry), data: { turbo: false } %>
93
+ <% if record %>
94
+ <%= link_to t("inline_forms.files.restore"),
95
+ inline_forms_file_trash_restore_path(entry, model: @model, state: @state, page: params[:page]),
96
+ data: { turbo_method: :post } %>
97
+ <% end %>
98
+ <% end %>
99
+ </td>
100
+ </tr>
101
+ <% end %>
102
+ </tbody>
103
+ </table>
104
+ <% if purgeable %>
105
+ <%= submit_tag t("inline_forms.files.global.purge_selected"), class: "button alert",
106
+ data: { turbo_confirm: t("inline_forms.files.global.confirm_purge_selected") } %>
107
+ <% end %>
108
+ <% end %>
109
+ <%= will_paginate @entries %>
110
+ <% end %>
111
+ </div>
112
+ </div>
@@ -0,0 +1,90 @@
1
+ <%# Per-record trash panel ("Removed files (n)"), beside Versions. Lists the
2
+ trash rows of the slots shown on this page (a Client tab only its own),
3
+ newest first; restored and purged rows stay as greyed history. The frame
4
+ is always rendered, empty or not, so a file action can stream into it.
5
+ Never loads file bytes (without_data). -%>
6
+ <% object = local_assigns[:object] || @object -%>
7
+ <% update_span = local_assigns[:update_span] || @update_span -%>
8
+ <% slots = local_assigns.key?(:slots) ? local_assigns[:slots] : @file_trash_slots -%>
9
+ <% open = local_assigns.key?(:open) ? local_assigns[:open] : @file_trash_open -%>
10
+ <% slots = Array(slots) -%>
11
+ <% entries = slots.empty? ? [] : InlineForms::TrashedFile.without_data.trash_entries.for_record(object).where(attribute_name: slots).newest_first.to_a -%>
12
+ <% slots_param = slots.join(",") -%>
13
+ <turbo-frame id="<%= update_span %>">
14
+ <% if entries.any? %>
15
+ <% in_trash = entries.count(&:trashed?) -%>
16
+ <% toggle_path = polymorphic_path(object, action: :file_trash, update: update_span, slots: slots_param, close: (open ? true : nil)) -%>
17
+ <div class="row form_element_header associated_auto_header callout inline_forms-file-trash">
18
+ <div class='medium-11 large-11 column' >
19
+ <%= t("inline_forms.files.trash_header", count: in_trash) %>
20
+ </div>
21
+ <div class='medium-1 large-1 column'>
22
+ <%= inline_forms_file_icon_link (open ? "fi-x" : "fi-list"),
23
+ (open ? t("inline_forms.files.close_trash") : t("inline_forms.files.open_trash")),
24
+ toggle_path,
25
+ { class: "button #{open ? 'close_button' : 'new_button'}", data: { turbo: true, turbo_frame: update_span } } %>
26
+ </div>
27
+ </div>
28
+ <% if open %>
29
+ <% if (stale = entries.select(&:expired?).reject { |e| e.purged? || e.restored? }).any? %>
30
+ <div class="row"><div class="small-12 column inline_forms-file-meta"><%= t("inline_forms.files.expired_pending", count: stale.size) %></div></div>
31
+ <% end %>
32
+ <% entries.each do |entry| %>
33
+ <% attribute = entry.attribute_name -%>
34
+ <% field_frame = inline_forms_file_field_frame_id(object, attribute) -%>
35
+ <div class="row <%= cycle('odd', 'even') %> inline_forms-file-trash-row<%= ' inline_forms-file-trash-history' unless entry.trashed? %>">
36
+ <div class="small-12 medium-3 column">
37
+ <strong><%= object.class.human_attribute_name(attribute) %></strong>
38
+ </div>
39
+ <div class="small-12 medium-5 column">
40
+ <span class="inline_forms-file-name"><%= inline_forms_file_entry_name(entry) %></span>
41
+ <span class="inline_forms-file-meta">
42
+ <%= inline_forms_file_size(entry.byte_size) %> &middot;
43
+ <%= t("inline_forms.files.reason.#{entry.reason}") %>
44
+ <%= t("inline_forms.files.by_on", user: inline_forms_file_user_name(entry.trashed_by_id), at: l(entry.trashed_at, format: :short)) %>
45
+ </span>
46
+ <span class="inline_forms-file-meta">
47
+ <% if entry.state == :restored %>
48
+ <%= t("inline_forms.files.state_restored", user: inline_forms_file_user_name(entry.restored_by_id), at: l(entry.restored_at, format: :short)) %>
49
+ <% elsif entry.state == :purged %>
50
+ <%= t("inline_forms.files.state_purged", user: inline_forms_file_user_name(entry.purged_by_id), at: l(entry.purged_at, format: :short)) %>
51
+ <% elsif entry.state == :expired %>
52
+ <%= t("inline_forms.files.state_expired") %>
53
+ <% else %>
54
+ <% if entry.purge_after %>
55
+ <% days_left = ((entry.purge_after - Time.current) / 1.day).ceil -%>
56
+ <span class="<%= 'inline_forms-file-expiring' if days_left <= 7 %>"><%= t("inline_forms.files.expires_in", count: days_left, date: l(entry.purge_after.to_date)) %></span>
57
+ <% end %>
58
+ <% end %>
59
+ </span>
60
+ </div>
61
+ <div class="small-12 medium-4 column inline_forms-file-actions">
62
+ <% if entry.trashed? %>
63
+ <% if inline_forms_file_can?(:download_trashed_file, object, attribute) %>
64
+ <%= link_to t("inline_forms.files.download"),
65
+ polymorphic_path(object, action: :trashed_file, attribute: attribute, trash_id: entry.id),
66
+ data: { turbo: false } %>
67
+ <% end %>
68
+ <% if inline_forms_file_can?(:restore_file, object, attribute) %>
69
+ <% occupied = object.inline_forms_file_present?(attribute) -%>
70
+ <% if inline_forms_file_can?(occupied ? :replace_file : :update, object, attribute) %>
71
+ <% confirm = occupied ? t("inline_forms.files.confirm_restore_swap", current: object[attribute], filename: inline_forms_file_entry_name(entry)) : nil -%>
72
+ <%= link_to t("inline_forms.files.restore"),
73
+ polymorphic_path(object, action: :restore_file, attribute: attribute, trash_id: entry.id,
74
+ update: field_frame, slots: slots_param, panel: "open"),
75
+ inline_forms_file_post_data(update_span, confirm: confirm) %>
76
+ <% end %>
77
+ <% end %>
78
+ <% if inline_forms_file_can?(:purge_file, object, attribute) %>
79
+ <%= link_to t("inline_forms.files.purge"),
80
+ polymorphic_path(object, action: :purge_file, attribute: attribute, trash_id: entry.id,
81
+ update: field_frame, slots: slots_param, panel: "open"),
82
+ inline_forms_file_post_data(update_span, confirm: t("inline_forms.files.confirm_purge", filename: inline_forms_file_entry_name(entry))).deep_merge(class: "alert") %>
83
+ <% end %>
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+ <% end %>
88
+ <% end %>
89
+ <% end %>
90
+ </turbo-frame>
@@ -3,7 +3,11 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title><%= t('application_name') + " v" + InlineForms::VERSION -%></title>
6
+ <%# A missing translation must not reach <title>: a bare t() returns an %>
7
+ <%# html_safe <span class="translation_missing"> that shows up as markup. %>
8
+ <%# The `default:` (the host's ApplicationHelper#application_name, which %>
9
+ <%# the header partial needs anyway) makes t() return plain, escaped text. %>
10
+ <title><%= t('application_name', default: application_name) %> v<%= InlineForms::VERSION %></title>
7
11
  <%= stylesheet_link_tag "application" %>
8
12
  <%= stylesheet_link_tag "inline_forms_fonts" %>
9
13
  <%= stylesheet_link_tag "inline_forms/inline_forms" %>