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,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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 (`
|
|
4
|
-
|
|
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 (#{
|
|
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
|
+
|
|
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 =
|
|
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-
|
|
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
|
+
·
|
|
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> <%= 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) %> · <%= 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) %> ·
|
|
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
|
|
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" %>
|