mensa 0.2.5 → 0.2.6
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/.devcontainer/Dockerfile +6 -2
- data/.devcontainer/compose.yaml +1 -1
- data/.devcontainer/devcontainer.json +31 -29
- data/.devcontainer/postCreate.sh +8 -0
- data/.devcontainer/postStart.sh +9 -0
- data/.gitignore +3 -1
- data/.zed/tasks.json +12 -0
- data/Gemfile.lock +155 -153
- data/Procfile +1 -1
- data/README.md +85 -60
- data/app/assets/stylesheets/mensa/application.css +14 -11
- data/app/components/mensa/add_filter/component.css +110 -5
- data/app/components/mensa/add_filter/component.html.slim +10 -12
- data/app/components/mensa/add_filter/component.rb +7 -1
- data/app/components/mensa/add_filter/component_controller.js +697 -83
- data/app/components/mensa/cell/component.css +9 -0
- data/app/components/mensa/column_customizer/component.css +40 -0
- data/app/components/mensa/column_customizer/component.html.slim +14 -0
- data/app/components/mensa/column_customizer/component.rb +13 -0
- data/app/components/mensa/column_customizer/component_controller.js +383 -0
- data/app/components/mensa/control_bar/component.css +127 -4
- data/app/components/mensa/control_bar/component.html.slim +41 -14
- data/app/components/mensa/control_bar/component.rb +0 -4
- data/app/components/mensa/empty_state/component.css +20 -0
- data/app/components/mensa/empty_state/component.html.slim +7 -0
- data/app/components/mensa/empty_state/component.rb +18 -0
- data/app/components/mensa/filter_pill/component.css +23 -0
- data/app/components/mensa/filter_pill/component.html.slim +9 -6
- data/app/components/mensa/filter_pill/component.rb +9 -0
- data/app/components/mensa/filter_pill/component_controller.js +50 -10
- data/app/components/mensa/filter_pill_list/component.css +58 -9
- data/app/components/mensa/filter_pill_list/component.html.slim +11 -8
- data/app/components/mensa/filter_pill_list/component_controller.js +747 -48
- data/app/components/mensa/header/component.css +41 -43
- data/app/components/mensa/header/component.html.slim +7 -7
- data/app/components/mensa/row_action/component.html.slim +2 -2
- data/app/components/mensa/search/component.css +68 -9
- data/app/components/mensa/search/component.html.slim +19 -15
- data/app/components/mensa/search/component_controller.js +39 -49
- data/app/components/mensa/selection/component_controller.js +147 -0
- data/app/components/mensa/table/component.css +28 -0
- data/app/components/mensa/table/component.html.slim +9 -6
- data/app/components/mensa/table/component.rb +1 -0
- data/app/components/mensa/table/component_controller.js +524 -88
- data/app/components/mensa/table_row/component.css +6 -0
- data/app/components/mensa/table_row/component.html.slim +8 -3
- data/app/components/mensa/view/component.css +97 -29
- data/app/components/mensa/view/component.html.slim +23 -10
- data/app/components/mensa/view/component.rb +5 -0
- data/app/components/mensa/views/component.css +106 -13
- data/app/components/mensa/views/component.html.slim +51 -17
- data/app/components/mensa/views/component_controller.js +245 -20
- data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
- data/app/controllers/mensa/tables/exports_controller.rb +96 -0
- data/app/controllers/mensa/tables/filters_controller.rb +4 -1
- data/app/controllers/mensa/tables/views_controller.rb +108 -0
- data/app/controllers/mensa/tables_controller.rb +3 -6
- data/app/helpers/mensa/application_helper.rb +4 -0
- data/app/javascript/mensa/application.js +2 -2
- data/app/javascript/mensa/controllers/index.js +13 -4
- data/app/jobs/mensa/export_job.rb +77 -84
- data/app/models/mensa/export.rb +93 -0
- data/app/tables/mensa/base.rb +103 -12
- data/app/tables/mensa/batch_action.rb +27 -0
- data/app/tables/mensa/cell.rb +15 -0
- data/app/tables/mensa/column.rb +15 -2
- data/app/tables/mensa/config/batch_dsl.rb +13 -0
- data/app/tables/mensa/config/column_dsl.rb +1 -0
- data/app/tables/mensa/config/filter_dsl.rb +4 -1
- data/app/tables/mensa/config/render_dsl.rb +1 -1
- data/app/tables/mensa/config/table_dsl.rb +12 -5
- data/app/tables/mensa/config/view_dsl.rb +2 -0
- data/app/tables/mensa/config_readers.rb +20 -1
- data/app/tables/mensa/filter.rb +86 -3
- data/app/tables/mensa/scope.rb +24 -12
- data/app/views/mensa/exports/_badge.html.slim +5 -0
- data/app/views/mensa/exports/_dialog.html.slim +42 -0
- data/app/views/mensa/exports/_list.html.slim +29 -0
- data/app/views/mensa/tables/filters/show.turbo_stream.slim +35 -8
- data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
- data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
- data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
- data/config/locales/en.yml +44 -0
- data/config/locales/nl.yml +45 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
- data/docs/columns.png +0 -0
- data/docs/export.png +0 -0
- data/docs/filters.png +0 -0
- data/docs/table.png +0 -0
- data/lib/mensa/configuration.rb +33 -12
- data/lib/mensa/engine.rb +7 -2
- data/lib/mensa/version.rb +1 -1
- data/mensa.gemspec +2 -1
- data/mise.toml +8 -0
- data/package-lock.json +0 -7
- metadata +50 -8
data/app/tables/mensa/base.rb
CHANGED
|
@@ -11,12 +11,11 @@ module Mensa
|
|
|
11
11
|
attr_reader :params
|
|
12
12
|
|
|
13
13
|
config_reader :model
|
|
14
|
+
config_reader :scope
|
|
14
15
|
config_reader :link, call: false
|
|
15
16
|
config_reader :supports_views?
|
|
16
17
|
config_reader :supports_custom_views?
|
|
17
18
|
config_reader :supports_filters?
|
|
18
|
-
config_reader :view_condensed?
|
|
19
|
-
config_reader :view_condensed_toggle?
|
|
20
19
|
config_reader :view_columns_ordering?
|
|
21
20
|
config_reader :show_header?
|
|
22
21
|
config_reader :exportable?
|
|
@@ -25,10 +24,26 @@ module Mensa
|
|
|
25
24
|
def initialize(config = {})
|
|
26
25
|
@params = config.to_h.deep_symbolize_keys
|
|
27
26
|
@config = self.class.definition.merge(@params || {})
|
|
27
|
+
|
|
28
|
+
ensure_internal_columns_for_joined_associations
|
|
29
|
+
|
|
30
|
+
params[:hidden_columns]&.each do |column_name|
|
|
31
|
+
c = columns.find { |c| c.name == column_name.to_sym }
|
|
32
|
+
c.config[:visible] = false
|
|
33
|
+
end
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def column_order
|
|
31
|
-
config[:column_order] || config[:columns]&.keys
|
|
37
|
+
order = config[:column_order].presence || config[:columns]&.keys
|
|
38
|
+
order = order&.map(&:to_sym)
|
|
39
|
+
return order if order.nil?
|
|
40
|
+
|
|
41
|
+
# Internal columns are never shown in the column customizer UI, so they
|
|
42
|
+
# are absent from any URL-supplied column_order. Always append them so
|
|
43
|
+
# that columns and selected_scope include their attributes.
|
|
44
|
+
all_keys = (config[:columns]&.keys || []).map(&:to_sym)
|
|
45
|
+
internal_keys = all_keys.select { |key| config.dig(:columns, key, :internal) }
|
|
46
|
+
(order | internal_keys)
|
|
32
47
|
end
|
|
33
48
|
|
|
34
49
|
# Returns all columns
|
|
@@ -42,9 +57,15 @@ module Mensa
|
|
|
42
57
|
columns.find { |c| c.name == name.to_sym }
|
|
43
58
|
end
|
|
44
59
|
|
|
45
|
-
# Returns the columns to be displayed
|
|
60
|
+
# Returns the columns to be displayed, ordered by column_order.
|
|
46
61
|
def display_columns
|
|
47
|
-
@display_columns ||=
|
|
62
|
+
@display_columns ||= begin
|
|
63
|
+
order = column_order || []
|
|
64
|
+
columns
|
|
65
|
+
.select(&:visible?)
|
|
66
|
+
.reject(&:internal?)
|
|
67
|
+
.sort_by { |col| order.index(col.name) || Float::INFINITY }
|
|
68
|
+
end
|
|
48
69
|
end
|
|
49
70
|
|
|
50
71
|
# Returns the rows to be displayed
|
|
@@ -57,8 +78,8 @@ module Mensa
|
|
|
57
78
|
end
|
|
58
79
|
|
|
59
80
|
def system_views
|
|
60
|
-
config[:views]&.key?(:default) ? [] : [Mensa::SystemView.new(:default, config: {name: I18n.t("mensa.views.default")}, table: self)]
|
|
61
|
-
|
|
81
|
+
views = config[:views]&.key?(:default) ? [] : [Mensa::SystemView.new(:default, config: {name: I18n.t("mensa.views.default")}, table: self)]
|
|
82
|
+
views + (config[:views] || {}).keys.map { |view_name| Mensa::SystemView.new(view_name, config: config.dig(:views, view_name), table: self) }
|
|
62
83
|
end
|
|
63
84
|
|
|
64
85
|
# Returns true if the table has filters
|
|
@@ -66,9 +87,13 @@ module Mensa
|
|
|
66
87
|
columns.any?(&:filter?)
|
|
67
88
|
end
|
|
68
89
|
|
|
69
|
-
# Returns the active filters
|
|
90
|
+
# Returns the active filters, skipping any whose column no longer exists.
|
|
70
91
|
def active_filters
|
|
71
|
-
(config[:filters] || {}).
|
|
92
|
+
(config[:filters] || {}).filter_map do |column_name, filter_config|
|
|
93
|
+
col = column(column_name)
|
|
94
|
+
next unless col
|
|
95
|
+
Mensa::Filter.new(column: col, config: filter_config, table: self)
|
|
96
|
+
end
|
|
72
97
|
end
|
|
73
98
|
|
|
74
99
|
def actions?
|
|
@@ -81,10 +106,27 @@ module Mensa
|
|
|
81
106
|
@actions ||= config[:actions].keys.map { |action_name| Mensa::Action.new(action_name, config: config.dig(:actions, action_name), table: self) }
|
|
82
107
|
end
|
|
83
108
|
|
|
109
|
+
def batch_actions?
|
|
110
|
+
config[:batches].present?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def batch_actions
|
|
114
|
+
return @batch_actions if @batch_actions
|
|
115
|
+
|
|
116
|
+
@batch_actions ||= config[:batches].keys.map { |batch_name| Mensa::BatchAction.new(batch_name, config: config.dig(:batches, batch_name), table: self) }
|
|
117
|
+
end
|
|
118
|
+
|
|
84
119
|
# Returns the current path with configuration
|
|
85
|
-
def path(order: {}, turbo_frame_id: nil, table_view_id:
|
|
120
|
+
def path(order: {}, turbo_frame_id: nil, table_view_id: params[:table_view_id], column_order: params[:column_order], hidden_columns: params[:hidden_columns])
|
|
86
121
|
# FIXME: if someone doesn't use as: :mensa in the routes, it breaks
|
|
87
|
-
original_view_context.mensa.table_path(
|
|
122
|
+
original_view_context.mensa.table_path(
|
|
123
|
+
name,
|
|
124
|
+
order: order_hash(order),
|
|
125
|
+
turbo_frame_id: turbo_frame_id,
|
|
126
|
+
table_view_id: table_view_id,
|
|
127
|
+
column_order: column_order,
|
|
128
|
+
hidden_columns: hidden_columns
|
|
129
|
+
)
|
|
88
130
|
end
|
|
89
131
|
|
|
90
132
|
def menu
|
|
@@ -95,10 +137,18 @@ module Mensa
|
|
|
95
137
|
|
|
96
138
|
def all_views
|
|
97
139
|
views = system_views
|
|
98
|
-
views += TableView.where(table_name: name).where(user: [nil,
|
|
140
|
+
views += TableView.where(table_name: name).where(user: [nil, current_user])
|
|
99
141
|
views
|
|
100
142
|
end
|
|
101
143
|
|
|
144
|
+
# The user that owns custom views. Returns nil when the host application has
|
|
145
|
+
# no current user, in which case views cannot be saved.
|
|
146
|
+
def current_user
|
|
147
|
+
return Current.user if defined?(Current) && Current.respond_to?(:user)
|
|
148
|
+
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
102
152
|
def table_id
|
|
103
153
|
return @table_id if @table_id
|
|
104
154
|
|
|
@@ -108,5 +158,46 @@ module Mensa
|
|
|
108
158
|
def original_view_context
|
|
109
159
|
@original_view_context || component.original_view_context
|
|
110
160
|
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def ensure_internal_columns_for_joined_associations
|
|
165
|
+
config[:columns] ||= {}
|
|
166
|
+
|
|
167
|
+
auto_internal_column_names.each do |column_name|
|
|
168
|
+
config[:columns][column_name] ||= {internal: true}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def auto_internal_column_names
|
|
173
|
+
joined_association_names.filter_map do |association_name|
|
|
174
|
+
reflection = model.reflect_on_association(association_name)
|
|
175
|
+
reflection&.foreign_key&.to_sym
|
|
176
|
+
end.uniq
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def joined_association_names
|
|
180
|
+
relation = scope
|
|
181
|
+
return [] unless relation.respond_to?(:joins_values) && relation.respond_to?(:left_outer_joins_values)
|
|
182
|
+
|
|
183
|
+
(relation.joins_values + relation.left_outer_joins_values).flat_map do |join_value|
|
|
184
|
+
association_names_from_join_value(join_value)
|
|
185
|
+
end.compact.uniq
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def association_names_from_join_value(join_value)
|
|
189
|
+
case join_value
|
|
190
|
+
when Symbol, String
|
|
191
|
+
[join_value.to_sym]
|
|
192
|
+
when Array
|
|
193
|
+
join_value.flat_map { |value| association_names_from_join_value(value) }
|
|
194
|
+
when Hash
|
|
195
|
+
join_value.flat_map do |association_name, nested_join_values|
|
|
196
|
+
[association_name.to_sym, *association_names_from_join_value(nested_join_values)]
|
|
197
|
+
end
|
|
198
|
+
else
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
111
202
|
end
|
|
112
203
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mensa
|
|
4
|
+
# Represents a batch action that can be applied to multiple selected records.
|
|
5
|
+
#
|
|
6
|
+
# batch :archive do
|
|
7
|
+
# title "Archive"
|
|
8
|
+
# process { |records| records.update_all(archived: true) }
|
|
9
|
+
# end
|
|
10
|
+
class BatchAction
|
|
11
|
+
include ConfigReaders
|
|
12
|
+
|
|
13
|
+
defined_by Mensa::Config::BatchDsl
|
|
14
|
+
|
|
15
|
+
attr_reader :name, :table
|
|
16
|
+
|
|
17
|
+
def initialize(name, config:, table:)
|
|
18
|
+
@name = name
|
|
19
|
+
@table = table
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config_reader :title
|
|
24
|
+
config_reader :description
|
|
25
|
+
config_reader :process, call: false
|
|
26
|
+
end
|
|
27
|
+
end
|
data/app/tables/mensa/cell.rb
CHANGED
|
@@ -46,5 +46,20 @@ module Mensa
|
|
|
46
46
|
column.sanitize? ? sanitize(value.to_s) : value.to_s.html_safe
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
|
+
|
|
50
|
+
def to_csv
|
|
51
|
+
case value
|
|
52
|
+
when NilClass
|
|
53
|
+
""
|
|
54
|
+
when TrueClass, FalseClass
|
|
55
|
+
value.to_s
|
|
56
|
+
when Date
|
|
57
|
+
respond_to?(:dt) ? dt(value) : value.strftime("%d.%m.%Y")
|
|
58
|
+
when Time, DateTime
|
|
59
|
+
respond_to?(:ln) ? ln(value) : value.strftime("%d-%m-%Y %H:%M:%S")
|
|
60
|
+
else
|
|
61
|
+
strip_tags(value.to_s)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
49
64
|
end
|
|
50
65
|
end
|
data/app/tables/mensa/column.rb
CHANGED
|
@@ -21,7 +21,8 @@ module Mensa
|
|
|
21
21
|
config_reader :method # When a method needs to be called on the model, slow!
|
|
22
22
|
|
|
23
23
|
def sort_direction
|
|
24
|
-
table.config.dig(:order, name)
|
|
24
|
+
value = table.config.dig(:order, name)
|
|
25
|
+
value.present? ? value.to_sym : nil
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def next_sort_direction
|
|
@@ -44,6 +45,18 @@ module Mensa
|
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
def active_record_column
|
|
49
|
+
@active_record_column ||= table.model&.columns&.find { _1.name == name.to_s }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def active_record_column_type
|
|
53
|
+
active_record_column&.type
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def type
|
|
57
|
+
config[:type] || active_record_column_type
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
def attribute_for_condition
|
|
48
61
|
return @attribute_for_condition if @attribute_for_condition
|
|
49
62
|
|
|
@@ -77,7 +90,7 @@ module Mensa
|
|
|
77
90
|
Satis::Menus::Builder.build(:filter_menu, event: "click") do |m|
|
|
78
91
|
if sortable?
|
|
79
92
|
m.item :sort_ascending, icon: "fa-solid fa-arrow-up-short-wide", link: table.path(order: {name => :asc}), link_attributes: {"data-turbo-frame": "_self"}
|
|
80
|
-
m.item :sort_descending, icon: "fa-solid fa-arrow-down-wide-short", link: table.path(order: {name => :
|
|
93
|
+
m.item :sort_descending, icon: "fa-solid fa-arrow-down-wide-short", link: table.path(order: {name => :desc}), link_attributes: {"data-turbo-frame": "_self"}
|
|
81
94
|
end
|
|
82
95
|
end
|
|
83
96
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mensa::Config
|
|
4
|
+
class BatchDsl
|
|
5
|
+
include DslLogic
|
|
6
|
+
|
|
7
|
+
option :title, default: -> { name.to_s.humanize }
|
|
8
|
+
option :description, default: ""
|
|
9
|
+
option :process, default: ->(records) {}
|
|
10
|
+
|
|
11
|
+
delegate :t, to: :I18n
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -2,9 +2,12 @@ module Mensa::Config
|
|
|
2
2
|
class FilterDsl
|
|
3
3
|
include DslLogic
|
|
4
4
|
|
|
5
|
-
option :operator, default: :
|
|
5
|
+
option :operator, default: :is
|
|
6
6
|
option :value
|
|
7
7
|
option :collection
|
|
8
8
|
option :scope
|
|
9
|
+
option :multiple, default: false
|
|
10
|
+
option :as
|
|
11
|
+
option :operators
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# render do # default Standard components
|
|
6
6
|
# html # Mensa::TableComponent::Default
|
|
7
7
|
# json # Mensa::JsonRenderer::Default
|
|
8
|
-
#
|
|
8
|
+
# csv # Mensa::CsvRenderer::Default
|
|
9
9
|
# end
|
|
10
10
|
#
|
|
11
11
|
# column(:first_name) do
|
|
@@ -55,6 +55,7 @@ module Mensa::Config
|
|
|
55
55
|
raise "No model found for #{self.class.name}"
|
|
56
56
|
end
|
|
57
57
|
}
|
|
58
|
+
option :scope, default: -> { model.all }
|
|
58
59
|
option :column, dsl_hash: Mensa::Config::ColumnDsl
|
|
59
60
|
option :link
|
|
60
61
|
|
|
@@ -70,6 +71,8 @@ module Mensa::Config
|
|
|
70
71
|
# Actions
|
|
71
72
|
option :action, dsl_hash: Mensa::Config::ActionDsl
|
|
72
73
|
|
|
74
|
+
option :batch, dsl_hash: Mensa::Config::BatchDsl
|
|
75
|
+
|
|
73
76
|
option :render, dsl: Mensa::Config::RenderDsl
|
|
74
77
|
|
|
75
78
|
option :supports_views, default: false
|
|
@@ -78,11 +81,15 @@ module Mensa::Config
|
|
|
78
81
|
option :show_header, default: true
|
|
79
82
|
# Whether the table allows to change column ordering
|
|
80
83
|
option :view_columns_ordering, default: true
|
|
81
|
-
# Whether to show a condensed view by default
|
|
82
|
-
option :view_condensed, default: false
|
|
83
|
-
# Whether to show the toggle for condensed view
|
|
84
|
-
option :view_condensed_toggle, default: true
|
|
85
84
|
|
|
86
85
|
option :view, dsl_hash: Mensa::Config::ViewDsl
|
|
86
|
+
|
|
87
|
+
# Syntactic sugar for `column :carrier_id do internal true end`.
|
|
88
|
+
def internal(name, &block)
|
|
89
|
+
column(name) do
|
|
90
|
+
internal true
|
|
91
|
+
instance_exec(&block) if block
|
|
92
|
+
end
|
|
93
|
+
end
|
|
87
94
|
end
|
|
88
95
|
end
|
|
@@ -8,8 +8,27 @@ module Mensa
|
|
|
8
8
|
|
|
9
9
|
class_methods do
|
|
10
10
|
def defined_by(dsl_class)
|
|
11
|
+
# Lazily-built DSL instance that accumulates configuration.
|
|
12
|
+
define_singleton_method(:dsl_definition) do
|
|
13
|
+
@dsl_definition ||= dsl_class.new(name)
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
define_singleton_method(:definition) do |&block|
|
|
12
|
-
|
|
17
|
+
dsl_definition.instance_eval(&block) if block
|
|
18
|
+
dsl_definition.config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Forward unknown class-level calls to the DSL
|
|
22
|
+
define_singleton_method(:method_missing) do |method_name, *args, &block|
|
|
23
|
+
if dsl_definition.respond_to?(method_name)
|
|
24
|
+
dsl_definition.public_send(method_name, *args, &block)
|
|
25
|
+
else
|
|
26
|
+
super(method_name, *args, &block)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
define_singleton_method(:respond_to_missing?) do |method_name, include_private = false|
|
|
31
|
+
dsl_definition.respond_to?(method_name, include_private) || super(method_name, include_private)
|
|
13
32
|
end
|
|
14
33
|
end
|
|
15
34
|
|
data/app/tables/mensa/filter.rb
CHANGED
|
@@ -11,6 +11,23 @@ module Mensa
|
|
|
11
11
|
config_reader :operator, cast: :to_sym
|
|
12
12
|
config_reader :value
|
|
13
13
|
config_reader :scope
|
|
14
|
+
config_reader :multiple
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def OPERATORS
|
|
18
|
+
[
|
|
19
|
+
[:is, I18n.t("mensa.operators.is"), true],
|
|
20
|
+
[:isnt, I18n.t("mensa.operators.isnt"), true],
|
|
21
|
+
[:matches, I18n.t("mensa.operators.matches"), true],
|
|
22
|
+
[:does_not_match, I18n.t("mensa.operators.does_not_match"), true],
|
|
23
|
+
[:gt, I18n.t("mensa.operators.gt"), true],
|
|
24
|
+
[:gteq, I18n.t("mensa.operators.gteq"), true],
|
|
25
|
+
[:lt, I18n.t("mensa.operators.lt"), true],
|
|
26
|
+
[:lteq, I18n.t("mensa.operators.lteq"), true],
|
|
27
|
+
[:is_current, I18n.t("mensa.operators.is_current"), false]
|
|
28
|
+
].freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
14
31
|
|
|
15
32
|
def initialize(column:, config:, table:)
|
|
16
33
|
@column = column
|
|
@@ -18,6 +35,10 @@ module Mensa
|
|
|
18
35
|
@table = table
|
|
19
36
|
end
|
|
20
37
|
|
|
38
|
+
def multiple?
|
|
39
|
+
!!multiple
|
|
40
|
+
end
|
|
41
|
+
|
|
21
42
|
def collection
|
|
22
43
|
return unless config&.key?(:collection)
|
|
23
44
|
|
|
@@ -28,8 +49,19 @@ module Mensa
|
|
|
28
49
|
end
|
|
29
50
|
end
|
|
30
51
|
|
|
52
|
+
# This defines how the filter should be displayed in the value popover
|
|
53
|
+
# :select => as a select input
|
|
54
|
+
# :checkbox => as a checkbox input
|
|
55
|
+
# :string => as a text input
|
|
56
|
+
def as
|
|
57
|
+
config[:as]
|
|
58
|
+
end
|
|
59
|
+
|
|
31
60
|
def to_s
|
|
32
|
-
|
|
61
|
+
parts = [column.human_name, operator_label]
|
|
62
|
+
formatted_value = value.is_a?(Array) ? value.join(", ") : value
|
|
63
|
+
parts << formatted_value if formatted_value.present? && operator_with_value?
|
|
64
|
+
parts.join(" ")
|
|
33
65
|
end
|
|
34
66
|
|
|
35
67
|
def filter_scope(record_scope)
|
|
@@ -37,10 +69,26 @@ module Mensa
|
|
|
37
69
|
record_scope.instance_exec(normalize(value), &scope)
|
|
38
70
|
else
|
|
39
71
|
case operator
|
|
72
|
+
when :is_current
|
|
73
|
+
record_scope.where("#{column.attribute_for_condition} = ?", Current.send(column.name))
|
|
40
74
|
when :matches
|
|
41
75
|
record_scope.where("#{column.attribute_for_condition} LIKE ?", "%#{normalize(value)}%")
|
|
42
|
-
when :
|
|
43
|
-
record_scope.where(column.attribute_for_condition
|
|
76
|
+
when :does_not_match
|
|
77
|
+
record_scope.where("#{column.attribute_for_condition} NOT LIKE ?", "%#{normalize(value)}%")
|
|
78
|
+
when :is
|
|
79
|
+
val = value.is_a?(Array) ? value : normalize(value)
|
|
80
|
+
record_scope.where(column.attribute_for_condition => val)
|
|
81
|
+
when :isnt
|
|
82
|
+
val = value.is_a?(Array) ? value : normalize(value)
|
|
83
|
+
record_scope.where.not(column.attribute_for_condition => val)
|
|
84
|
+
when :gt
|
|
85
|
+
record_scope.where(column.table.model.arel_table[column.attribute_for_condition].gt(normalize(value)))
|
|
86
|
+
when :lt
|
|
87
|
+
record_scope.where(column.table.model.arel_table[column.attribute_for_condition].lt(normalize(value)))
|
|
88
|
+
when :gteq
|
|
89
|
+
record_scope.where(column.table.model.arel_table[column.attribute_for_condition].gteq(normalize(value)))
|
|
90
|
+
when :lteq
|
|
91
|
+
record_scope.where(column.table.model.arel_table[column.attribute_for_condition].lteq(normalize(value)))
|
|
44
92
|
else
|
|
45
93
|
# Ignore unknown operators
|
|
46
94
|
record_scope
|
|
@@ -48,6 +96,41 @@ module Mensa
|
|
|
48
96
|
end
|
|
49
97
|
end
|
|
50
98
|
|
|
99
|
+
def operators
|
|
100
|
+
operators = Mensa::Filter.OPERATORS.dup
|
|
101
|
+
if config[:operators].present?
|
|
102
|
+
operators = operators.select { |op| config[:operators].include?(op[0]) }
|
|
103
|
+
else
|
|
104
|
+
operators.delete_if { |op| op[0] == :is_current } unless Current.method_defined?(column.name, false)
|
|
105
|
+
operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } if collection.present?
|
|
106
|
+
operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } if column.type == :integer || column.type == :date || column.type == :datetime
|
|
107
|
+
operators.delete_if { |op| [:is, :isnt].include?(op[0]) } if column.type == :date || column.type == :datetime
|
|
108
|
+
operators.delete_if { |op| [:gt, :lt, :gteq, :lteq].include?(op[0]) } if column.type == :string || column.type.blank?
|
|
109
|
+
end
|
|
110
|
+
operators
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def operator_label
|
|
114
|
+
Mensa::Filter.OPERATORS.find { |op| op[0] == operator }[1]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def operator_with_value?
|
|
118
|
+
Mensa::Filter.OPERATORS.find { |op| op[0] == operator }[2]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def input_type
|
|
122
|
+
case column.type
|
|
123
|
+
when :integer
|
|
124
|
+
"number"
|
|
125
|
+
when :date
|
|
126
|
+
"date"
|
|
127
|
+
when :datetime
|
|
128
|
+
"datetime-local"
|
|
129
|
+
else
|
|
130
|
+
"text"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
51
134
|
private
|
|
52
135
|
|
|
53
136
|
def normalize(query)
|
data/app/tables/mensa/scope.rb
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mensa
|
|
4
|
+
# scope -> filtered_scope -> ordered_scope -> selected_scope ->
|
|
4
5
|
module Scope
|
|
5
6
|
extend ActiveSupport::Concern
|
|
6
7
|
|
|
7
8
|
included do
|
|
8
9
|
end
|
|
9
10
|
|
|
10
|
-
# Returns the records we want to display, using the Active Record Query Interface
|
|
11
|
-
# By default it returns all records
|
|
12
|
-
def scope
|
|
13
|
-
model.all
|
|
14
|
-
end
|
|
15
|
-
|
|
16
11
|
# Returns the scope, but filtered
|
|
17
12
|
def filtered_scope
|
|
18
13
|
return @filtered_scope if @filtered_scope
|
|
@@ -20,7 +15,13 @@ module Mensa
|
|
|
20
15
|
@filtered_scope = scope
|
|
21
16
|
# See https://github.com/textacular/textacular
|
|
22
17
|
# This has problems - not all table fields are searched
|
|
23
|
-
|
|
18
|
+
if params[:query].present?
|
|
19
|
+
@filtered_scope = if Mensa.config.search == :fuzzy
|
|
20
|
+
@filtered_scope.fuzzy_search(params[:query])
|
|
21
|
+
else
|
|
22
|
+
@filtered_scope.basic_search(params[:query])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
24
25
|
|
|
25
26
|
# Use inject
|
|
26
27
|
active_filters.each do |filter|
|
|
@@ -35,7 +36,7 @@ module Mensa
|
|
|
35
36
|
return @ordered_scope if @ordered_scope
|
|
36
37
|
|
|
37
38
|
@ordered_scope = filtered_scope
|
|
38
|
-
@ordered_scope = @ordered_scope.reorder(
|
|
39
|
+
@ordered_scope = @ordered_scope.reorder(effective_order)
|
|
39
40
|
|
|
40
41
|
@ordered_scope
|
|
41
42
|
end
|
|
@@ -68,11 +69,22 @@ module Mensa
|
|
|
68
69
|
@pagy_details, @records = selected_scope.is_a?(Array) ? pagy(:offset, ordered_scope, anchor_string: 'data-turbo-frame="_self"') : pagy(:offset, selected_scope, anchor_string: 'data-turbo-frame="_self"')
|
|
69
70
|
end
|
|
70
71
|
|
|
71
|
-
#
|
|
72
|
+
# Effective ordering for SQL: when the request includes any order[] params
|
|
73
|
+
# (even with blank values), use only those — blank means "explicitly no sort".
|
|
74
|
+
# Falls back to the view/config default only when no order params were sent.
|
|
75
|
+
def effective_order
|
|
76
|
+
result = params.key?(:order) ? (params[:order] || {}) : (config[:order] || {})
|
|
77
|
+
result = result.symbolize_keys.compact_blank.transform_values(&:to_sym)
|
|
78
|
+
result.transform_keys { column(_1).attribute_for_condition }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds an order hash for URL generation. Merges current order with overrides;
|
|
82
|
+
# nil values become "" so they appear in the URL as order[col]= (which tells
|
|
83
|
+
# the server the user explicitly cleared that column's sort direction).
|
|
72
84
|
def order_hash(new_params = {})
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
base = params[:order]&.symbolize_keys || config[:order]&.symbolize_keys || {}
|
|
86
|
+
merged = base.merge(new_params.symbolize_keys)
|
|
87
|
+
merged.transform_values { |v| v.nil? ? "" : v.to_sym }
|
|
76
88
|
end
|
|
77
89
|
end
|
|
78
90
|
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/ Download counter shown on the export button. Hidden while there are no
|
|
2
|
+
/ completed downloads. Replaced via Turbo stream when a job finishes.
|
|
3
|
+
- count = Mensa::Export.completed_count(table_name, user)
|
|
4
|
+
span id=Mensa::Export.badge_dom_id(table_name, user) class="mensa-table__control_bar__badge #{'hidden' unless count.positive?}" aria-hidden=(count.positive? ? "false" : "true")
|
|
5
|
+
= count
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/ Export dialog rendered once per table inside the control bar. Opening it
|
|
2
|
+
/ refreshes the downloads list; submitting the form creates a new export.
|
|
3
|
+
- user = table.current_user
|
|
4
|
+
- exports = Mensa::Export.for_table(table.name).for_user(user).recent
|
|
5
|
+
dialog.mensa-table__export-dialog data-mensa-table-target="exportDialog" data-action="click->mensa-table#exportDialogBackdrop"
|
|
6
|
+
.mensa-table__export-dialog__panel
|
|
7
|
+
header.mensa-table__export-dialog__header
|
|
8
|
+
h2.mensa-table__export-dialog__title
|
|
9
|
+
= t("mensa.exports.title", default: "Export %{table}", table: table.name.to_s.humanize)
|
|
10
|
+
button.mensa-table__export-dialog__close type="button" data-action="mensa-table#cancelExport" aria-label=t("mensa.exports.close", default: "Close")
|
|
11
|
+
i.fa-solid.fa-xmark
|
|
12
|
+
|
|
13
|
+
.mensa-table__export-dialog__body
|
|
14
|
+
= render partial: "mensa/exports/list", locals: {table_name: table.name, user: user, exports: exports}
|
|
15
|
+
|
|
16
|
+
form.mensa-table__export-dialog__form data-action="submit->mensa-table#confirmExport"
|
|
17
|
+
fieldset.mensa-table__export-dialog__fieldset
|
|
18
|
+
legend.mensa-table__export-dialog__section-title
|
|
19
|
+
= t("mensa.exports.new_export", default: "New export")
|
|
20
|
+
label.mensa-table__export-dialog__option
|
|
21
|
+
input type="radio" name="scope" value="all" checked=true
|
|
22
|
+
span = t("mensa.exports.scope_all", default: "All records (matching current filters)")
|
|
23
|
+
label.mensa-table__export-dialog__option
|
|
24
|
+
input type="radio" name="scope" value="current_page"
|
|
25
|
+
span = t("mensa.exports.scope_current_page", default: "Current page")
|
|
26
|
+
|
|
27
|
+
fieldset.mensa-table__export-dialog__fieldset
|
|
28
|
+
legend.mensa-table__export-dialog__section-title
|
|
29
|
+
= t("mensa.exports.export_as", default: "Export as")
|
|
30
|
+
label.mensa-table__export-dialog__option
|
|
31
|
+
input type="radio" name="export_format" value="csv_excel" checked=true
|
|
32
|
+
span = t("mensa.exports.format_excel", default: "CSV for Excel, Numbers, or other spreadsheet programs")
|
|
33
|
+
label.mensa-table__export-dialog__option
|
|
34
|
+
input type="radio" name="export_format" value="plain_csv"
|
|
35
|
+
span = t("mensa.exports.format_plain", default: "Plain CSV file")
|
|
36
|
+
|
|
37
|
+
.mensa-table__export-dialog__actions
|
|
38
|
+
button.mensa-table__export-dialog__button.mensa-table__export-dialog__button--secondary type="button" data-action="mensa-table#cancelExport"
|
|
39
|
+
= t("mensa.exports.cancel", default: "Cancel")
|
|
40
|
+
button.mensa-table__export-dialog__button.mensa-table__export-dialog__button--primary type="submit"
|
|
41
|
+
i.fa-solid.fa-file-export
|
|
42
|
+
= t("mensa.exports.submit", default: "Export")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/ The list of a user's downloads for a table. Replaced via Turbo stream when a
|
|
2
|
+
/ new export is created or finishes processing.
|
|
3
|
+
div id=Mensa::Export.list_dom_id(table_name, user) class="mensa-table__export-dialog__downloads"
|
|
4
|
+
h3.mensa-table__export-dialog__section-title
|
|
5
|
+
= t("mensa.exports.available_downloads", default: "Available downloads")
|
|
6
|
+
- if exports.blank?
|
|
7
|
+
p.mensa-table__export-dialog__empty
|
|
8
|
+
= t("mensa.exports.empty", default: "You have no downloads yet. Create one below.")
|
|
9
|
+
- else
|
|
10
|
+
ul.mensa-table__export-dialog__list
|
|
11
|
+
- exports.each do |export|
|
|
12
|
+
li.mensa-table__export-dialog__item
|
|
13
|
+
.mensa-table__export-dialog__item-info
|
|
14
|
+
span.mensa-table__export-dialog__item-name
|
|
15
|
+
= export.filename.presence || t("mensa.exports.item_name", default: "%{table} export", table: export.table_name.to_s.humanize)
|
|
16
|
+
span.mensa-table__export-dialog__item-meta
|
|
17
|
+
= export.created_at.strftime("%Y-%m-%d %H:%M")
|
|
18
|
+
.mensa-table__export-dialog__item-action
|
|
19
|
+
- if export.downloadable?
|
|
20
|
+
= link_to mensa.download_table_export_path(export.table_name, export), class: "mensa-table__export-dialog__download", data: {turbo: false} do
|
|
21
|
+
i.fa-solid.fa-download
|
|
22
|
+
= t("mensa.exports.download", default: "Download")
|
|
23
|
+
- elsif export.failed?
|
|
24
|
+
span.mensa-table__export-dialog__status.mensa-table__export-dialog__status--failed
|
|
25
|
+
= t("mensa.exports.failed", default: "Failed")
|
|
26
|
+
- else
|
|
27
|
+
span.mensa-table__export-dialog__status.mensa-table__export-dialog__status--pending
|
|
28
|
+
i.fa-solid.fa-spinner.fa-spin
|
|
29
|
+
= t("mensa.exports.processing", default: "Preparing…")
|