mensa 0.3.4 → 0.5.0
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/.zed/tasks.json +1 -1
- data/Gemfile.lock +1 -4
- data/README.md +26 -14
- data/app/components/mensa/add_filter/component.html.erb +1 -1
- data/app/components/mensa/column_customizer/component_controller.js +1 -1
- data/app/components/mensa/control_bar/component.css +23 -3
- data/app/components/mensa/control_bar/component.html.erb +5 -5
- data/app/components/mensa/empty_state/component.rb +1 -1
- data/app/components/mensa/filter_pill/component.css +17 -3
- data/app/components/mensa/table/component.html.erb +1 -1
- data/app/components/mensa/table/component.rb +3 -3
- data/app/components/mensa/table/component_controller.js +97 -28
- data/app/components/mensa/view/component.html.erb +1 -1
- data/app/components/mensa/view/component.rb +1 -1
- data/app/controllers/mensa/tables/exports_controller.rb +25 -5
- data/app/controllers/mensa/tables_controller.rb +7 -9
- data/app/helpers/mensa/application_helper.rb +2 -2
- data/app/jobs/mensa/export_job.rb +4 -0
- data/app/jobs/mensa/recurring_exports_job.rb +17 -0
- data/app/models/mensa/export.rb +54 -2
- data/app/tables/mensa/base.rb +52 -12
- data/app/tables/mensa/column.rb +16 -6
- data/app/tables/mensa/config/dsl_logic.rb +2 -2
- data/app/tables/mensa/config/table_dsl.rb +2 -0
- data/app/tables/mensa/filter.rb +8 -1
- data/app/tables/mensa/scope.rb +14 -13
- data/app/tables/mensa/search.rb +70 -0
- data/app/views/mensa/exports/_dialog.html.erb +23 -0
- data/app/views/mensa/exports/_list.html.erb +13 -0
- data/app/views/mensa/tables/show.html.erb +0 -2
- data/app/views/mensa/tables/standard_error.html.erb +9 -0
- data/config/locales/en.yml +22 -0
- data/config/locales/nl.yml +21 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20260612110000_add_repeat_to_mensa_exports.rb +8 -0
- data/lib/mensa/configuration.rb +6 -7
- data/lib/mensa/engine.rb +0 -1
- data/lib/mensa/version.rb +1 -1
- data/lib/tasks/mensa_tasks.rake +7 -0
- data/mensa.gemspec +0 -1
- metadata +6 -16
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Mensa
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
def table(name,
|
|
4
|
-
component = ::Mensa::Table::Component.new(name,
|
|
3
|
+
def table(name, params: {}, **options)
|
|
4
|
+
component = ::Mensa::Table::Component.new(name, params: params, **options)
|
|
5
5
|
component.original_view_context = self
|
|
6
6
|
render(component)
|
|
7
7
|
end
|
|
@@ -23,6 +23,7 @@ module Mensa
|
|
|
23
23
|
|
|
24
24
|
data, filename, content_type = generate(table, export)
|
|
25
25
|
|
|
26
|
+
export.asset.purge if export.asset.attached?
|
|
26
27
|
export.asset.attach(io: StringIO.new(data), filename: filename, content_type: content_type)
|
|
27
28
|
finalize(export, status: "completed", filename: filename)
|
|
28
29
|
|
|
@@ -94,6 +95,9 @@ module Mensa
|
|
|
94
95
|
def finalize(export, status:, filename: nil)
|
|
95
96
|
attributes = {status: status}
|
|
96
97
|
attributes[:filename] = filename if filename
|
|
98
|
+
|
|
99
|
+
attributes[:last_repeat_run_at] = Time.current if status == "completed" && export.repeat.present?
|
|
100
|
+
|
|
97
101
|
export.update(attributes)
|
|
98
102
|
# Refresh the export button badge (download count) and the downloads list
|
|
99
103
|
# inside the export dialog for everyone viewing this table.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Mensa
|
|
2
|
+
# Sweeps recurring exports and re-enqueues any whose next scheduled run is due.
|
|
3
|
+
# This job is intended to be invoked by a daily cron entry or scheduler.
|
|
4
|
+
class RecurringExportsJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(reference_time = Time.current)
|
|
8
|
+
reference_time = reference_time.in_time_zone if reference_time.respond_to?(:in_time_zone)
|
|
9
|
+
|
|
10
|
+
Mensa::Export.repeating.find_each do |export|
|
|
11
|
+
next unless export.repeat_due?(reference_time)
|
|
12
|
+
|
|
13
|
+
Mensa::ExportJob.perform_later(export)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/app/models/mensa/export.rb
CHANGED
|
@@ -7,17 +7,21 @@ module Mensa
|
|
|
7
7
|
STATUSES = %w[pending processing completed failed].freeze
|
|
8
8
|
FORMATS = %w[csv_excel plain_csv].freeze
|
|
9
9
|
SCOPES = %w[all current_page].freeze
|
|
10
|
+
REPEATS = ["", "daily", "weekly", "monthly", "quarterly", "bi-yearly", "yearly"].freeze
|
|
10
11
|
|
|
11
12
|
belongs_to :user, optional: true
|
|
12
13
|
has_one_attached :asset
|
|
13
14
|
|
|
14
15
|
validates :table_name, presence: true
|
|
15
16
|
validates :status, inclusion: {in: STATUSES}
|
|
17
|
+
validates :repeat, inclusion: {in: REPEATS}
|
|
16
18
|
|
|
17
19
|
scope :for_table, ->(table_name) { where(table_name: table_name.to_s) }
|
|
18
20
|
scope :for_user, ->(user) { where(user_id: user.respond_to?(:id) ? user&.id : user) }
|
|
19
21
|
scope :completed, -> { where(status: "completed") }
|
|
20
22
|
scope :recent, -> { order(created_at: :desc) }
|
|
23
|
+
scope :repeating, -> { where.not(repeat: [nil, ""]) }
|
|
24
|
+
scope :with_downloadable_asset, -> { completed.joins(:asset_attachment).distinct }
|
|
21
25
|
|
|
22
26
|
def completed?
|
|
23
27
|
status == "completed"
|
|
@@ -35,15 +39,63 @@ module Mensa
|
|
|
35
39
|
status == "pending"
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
def next_repeat_run_at(from: nil)
|
|
43
|
+
return if repeat.blank?
|
|
44
|
+
|
|
45
|
+
anchor = from || last_repeat_run_at || created_at
|
|
46
|
+
|
|
47
|
+
case repeat
|
|
48
|
+
when "daily"
|
|
49
|
+
anchor + 1.day
|
|
50
|
+
when "weekly"
|
|
51
|
+
anchor + 1.week
|
|
52
|
+
when "monthly"
|
|
53
|
+
anchor.advance(months: 1)
|
|
54
|
+
when "quarterly"
|
|
55
|
+
anchor.advance(months: 3)
|
|
56
|
+
when "bi-yearly"
|
|
57
|
+
anchor.advance(months: 6)
|
|
58
|
+
when "yearly"
|
|
59
|
+
anchor.advance(years: 1)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def repeat_due?(reference_time = Time.current)
|
|
64
|
+
return false if repeat.blank? || pending? || processing?
|
|
65
|
+
|
|
66
|
+
next_run_at = next_repeat_run_at
|
|
67
|
+
next_run_at.present? && next_run_at <= reference_time
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def repeating?
|
|
71
|
+
repeat.present?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def repeat_label
|
|
75
|
+
return if repeat.blank?
|
|
76
|
+
|
|
77
|
+
I18n.t(
|
|
78
|
+
"mensa.exports.repeats_with_interval",
|
|
79
|
+
default: "Repeats %{interval}",
|
|
80
|
+
interval: repeat_interval_label
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def repeat_interval_label
|
|
85
|
+
return if repeat.blank?
|
|
86
|
+
|
|
87
|
+
I18n.t("mensa.exports.repeat_intervals.#{repeat}", default: repeat)
|
|
88
|
+
end
|
|
89
|
+
|
|
38
90
|
# True once the asset is ready to be downloaded by the user.
|
|
39
91
|
def downloadable?
|
|
40
92
|
completed? && asset.attached?
|
|
41
93
|
end
|
|
42
94
|
|
|
43
|
-
# Number of
|
|
95
|
+
# Number of currently-downloadable exports for a table/user combination.
|
|
44
96
|
# This is the number rendered in the export button badge.
|
|
45
97
|
def self.completed_count(table_name, user)
|
|
46
|
-
for_table(table_name).for_user(user).
|
|
98
|
+
for_table(table_name).for_user(user).with_downloadable_asset.count
|
|
47
99
|
end
|
|
48
100
|
|
|
49
101
|
# A stable, page-independent key identifying the exports of a table/user
|
data/app/tables/mensa/base.rb
CHANGED
|
@@ -8,8 +8,8 @@ module Mensa
|
|
|
8
8
|
|
|
9
9
|
attr_writer :original_view_context
|
|
10
10
|
attr_accessor :component, :name, :table_view, :request
|
|
11
|
-
attr_reader :params
|
|
12
11
|
|
|
12
|
+
config_reader :params
|
|
13
13
|
config_reader :model
|
|
14
14
|
config_reader :scope
|
|
15
15
|
config_reader :link, call: false
|
|
@@ -22,12 +22,12 @@ module Mensa
|
|
|
22
22
|
config_reader :export_with_password?
|
|
23
23
|
|
|
24
24
|
def initialize(config = {})
|
|
25
|
-
|
|
26
|
-
@config = self.class.definition.merge(
|
|
25
|
+
normalized_config = config.to_h.deep_symbolize_keys
|
|
26
|
+
@config = self.class.definition.merge(normalized_config)
|
|
27
|
+
@params = (@config[:params].presence || {}).deep_symbolize_keys
|
|
28
|
+
@config[:params] = @params
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
params[:hidden_columns]&.each do |column_name|
|
|
30
|
+
current_hidden_columns&.each do |column_name|
|
|
31
31
|
c = columns.find { |c| c.name == column_name.to_sym }
|
|
32
32
|
c.config[:visible] = false
|
|
33
33
|
end
|
|
@@ -48,12 +48,17 @@ module Mensa
|
|
|
48
48
|
|
|
49
49
|
# Returns all columns
|
|
50
50
|
def columns
|
|
51
|
+
ensure_internal_columns_for_joined_associations
|
|
51
52
|
@columns ||= column_order.map { |column_name| Mensa::Column.new(column_name, config: config.dig(:columns, column_name), table: self) }
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# Returns a column by name
|
|
55
56
|
# @param [String] name
|
|
56
57
|
def column(name)
|
|
58
|
+
found_column = columns.find { |c| c.name == name.to_sym }
|
|
59
|
+
return found_column if found_column || @internal_columns_ensured
|
|
60
|
+
|
|
61
|
+
@columns = nil
|
|
57
62
|
columns.find { |c| c.name == name.to_sym }
|
|
58
63
|
end
|
|
59
64
|
|
|
@@ -117,16 +122,19 @@ module Mensa
|
|
|
117
122
|
end
|
|
118
123
|
|
|
119
124
|
# Returns the current path with configuration
|
|
120
|
-
def path(order: {}, turbo_frame_id:
|
|
125
|
+
def path(order: {}, turbo_frame_id: current_turbo_frame_id, table_view_id: current_table_view_id, column_order: current_column_order, hidden_columns: current_hidden_columns, user_params: nil)
|
|
121
126
|
# FIXME: if someone doesn't use as: :mensa in the routes, it breaks
|
|
122
|
-
original_view_context.mensa.table_path(
|
|
123
|
-
|
|
127
|
+
path = original_view_context.mensa.table_path(name)
|
|
128
|
+
query = {
|
|
129
|
+
params: user_params || params,
|
|
124
130
|
order: order_hash(order),
|
|
125
131
|
turbo_frame_id: turbo_frame_id,
|
|
126
132
|
table_view_id: table_view_id,
|
|
127
133
|
column_order: column_order,
|
|
128
134
|
hidden_columns: hidden_columns
|
|
129
|
-
|
|
135
|
+
}.compact.to_query
|
|
136
|
+
|
|
137
|
+
query.present? ? "#{path}?#{query}" : path
|
|
130
138
|
end
|
|
131
139
|
|
|
132
140
|
def all_views
|
|
@@ -146,21 +154,53 @@ module Mensa
|
|
|
146
154
|
def table_id
|
|
147
155
|
return @table_id if @table_id
|
|
148
156
|
|
|
149
|
-
@table_id =
|
|
157
|
+
@table_id = current_turbo_frame_id || "#{name.to_s.gsub("/", "__")}-#{SecureRandom.base36}"
|
|
150
158
|
end
|
|
151
159
|
|
|
152
160
|
def original_view_context
|
|
153
161
|
@original_view_context || component.original_view_context
|
|
154
162
|
end
|
|
155
163
|
|
|
164
|
+
def current_query
|
|
165
|
+
config[:query]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current_order
|
|
169
|
+
config[:order]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def current_order_provided?
|
|
173
|
+
config.key?(:order)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def current_table_view_id
|
|
177
|
+
config[:table_view_id]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def current_column_order
|
|
181
|
+
config[:column_order]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def current_hidden_columns
|
|
185
|
+
config[:hidden_columns]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def current_turbo_frame_id
|
|
189
|
+
config[:turbo_frame_id]
|
|
190
|
+
end
|
|
191
|
+
|
|
156
192
|
private
|
|
157
193
|
|
|
158
194
|
def ensure_internal_columns_for_joined_associations
|
|
195
|
+
return if @internal_columns_ensured
|
|
196
|
+
|
|
159
197
|
config[:columns] ||= {}
|
|
160
198
|
|
|
161
199
|
auto_internal_column_names.each do |column_name|
|
|
162
|
-
config[:columns][column_name] ||= {internal: true}
|
|
200
|
+
config[:columns][column_name] ||= {internal: true, filter: false}
|
|
163
201
|
end
|
|
202
|
+
|
|
203
|
+
@internal_columns_ensured = true
|
|
164
204
|
end
|
|
165
205
|
|
|
166
206
|
def auto_internal_column_names
|
data/app/tables/mensa/column.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Mensa
|
|
|
22
22
|
|
|
23
23
|
def sort_direction
|
|
24
24
|
value = table.config.dig(:order, name)
|
|
25
|
-
value.
|
|
25
|
+
value.presence&.to_sym
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def next_sort_direction
|
|
@@ -41,12 +41,22 @@ module Mensa
|
|
|
41
41
|
@attribute = if config[:attribute].present?
|
|
42
42
|
"#{config[:attribute]} AS #{name}"
|
|
43
43
|
elsif table.model.column_names.include? name.to_s
|
|
44
|
-
name
|
|
44
|
+
"#{table.model.table_name}.#{name}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def raw_attribute
|
|
49
|
+
return @raw_attribute if @raw_attribute
|
|
50
|
+
|
|
51
|
+
@raw_attribute = if config[:attribute].present?
|
|
52
|
+
config[:attribute]
|
|
53
|
+
elsif table.model.column_names.include? name.to_s
|
|
54
|
+
"#{table.model.table_name}.#{name}"
|
|
45
55
|
end
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
def active_record_column
|
|
49
|
-
@active_record_column ||= table.model&.columns&.find {
|
|
59
|
+
@active_record_column ||= table.model&.columns&.find { |column| column.name == name.to_s }
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def active_record_column_type
|
|
@@ -61,15 +71,15 @@ module Mensa
|
|
|
61
71
|
return @attribute_for_condition if @attribute_for_condition
|
|
62
72
|
|
|
63
73
|
@attribute_for_condition = if config[:attribute].present?
|
|
64
|
-
|
|
74
|
+
raw_attribute
|
|
65
75
|
elsif table.model.column_names.include? name.to_s
|
|
66
|
-
name
|
|
76
|
+
"#{table.model.table_name}.#{name}"
|
|
67
77
|
end
|
|
68
78
|
end
|
|
69
79
|
|
|
70
80
|
# Returns true if the column supports filtering
|
|
71
81
|
def filter?
|
|
72
|
-
config
|
|
82
|
+
config[:filter] != false
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
def filter
|
|
@@ -59,8 +59,8 @@ module Mensa::Config
|
|
|
59
59
|
# by calling option :render, dsl: Mensa::RenderDsl
|
|
60
60
|
#
|
|
61
61
|
def dsl_option(option_name, klass)
|
|
62
|
-
define_method(option_name) do |
|
|
63
|
-
config[option_name.to_sym] = block
|
|
62
|
+
define_method(option_name) do |value = nil, &block|
|
|
63
|
+
config[option_name.to_sym] = block ? klass.new(value, &block).config : value
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -48,6 +48,7 @@ module Mensa::Config
|
|
|
48
48
|
class TableDsl
|
|
49
49
|
include DslLogic
|
|
50
50
|
|
|
51
|
+
option :params, default: {}
|
|
51
52
|
option :model, default: -> {
|
|
52
53
|
begin
|
|
53
54
|
self.class.name.demodulize.to_s.classify.gsub("Table", "").singularize.constantize
|
|
@@ -88,6 +89,7 @@ module Mensa::Config
|
|
|
88
89
|
def internal(name, &block)
|
|
89
90
|
column(name) do
|
|
90
91
|
internal true
|
|
92
|
+
filter false
|
|
91
93
|
instance_exec(&block) if block
|
|
92
94
|
end
|
|
93
95
|
end
|
data/app/tables/mensa/filter.rb
CHANGED
|
@@ -24,7 +24,8 @@ module Mensa
|
|
|
24
24
|
[:gteq, I18n.t("mensa.operators.gteq"), true],
|
|
25
25
|
[:lt, I18n.t("mensa.operators.lt"), true],
|
|
26
26
|
[:lteq, I18n.t("mensa.operators.lteq"), true],
|
|
27
|
-
[:is_current, I18n.t("mensa.operators.is_current"), false]
|
|
27
|
+
[:is_current, I18n.t("mensa.operators.is_current"), false],
|
|
28
|
+
[:is_empty, I18n.t("mensa.operators.is_empty"), false]
|
|
28
29
|
].freeze
|
|
29
30
|
end
|
|
30
31
|
end
|
|
@@ -69,6 +70,12 @@ module Mensa
|
|
|
69
70
|
record_scope.instance_exec(normalize(value), &scope)
|
|
70
71
|
else
|
|
71
72
|
case operator
|
|
73
|
+
when :is_empty
|
|
74
|
+
if column.type == :string
|
|
75
|
+
record_scope.where("#{column.attribute_for_condition} IS NULL OR #{column.attribute_for_condition} = ''")
|
|
76
|
+
else
|
|
77
|
+
record_scope.where("#{column.attribute_for_condition} IS NULL")
|
|
78
|
+
end
|
|
72
79
|
when :is_current
|
|
73
80
|
record_scope.where("#{column.attribute_for_condition} = ?", Current.send(column.name))
|
|
74
81
|
when :matches
|
data/app/tables/mensa/scope.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Mensa
|
|
|
4
4
|
# scope -> filtered_scope -> ordered_scope -> selected_scope ->
|
|
5
5
|
module Scope
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
|
+
include Search
|
|
7
8
|
|
|
8
9
|
included do
|
|
9
10
|
end
|
|
@@ -13,15 +14,7 @@ module Mensa
|
|
|
13
14
|
return @filtered_scope if @filtered_scope
|
|
14
15
|
|
|
15
16
|
@filtered_scope = scope
|
|
16
|
-
|
|
17
|
-
# This has problems - not all table fields are searched
|
|
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
|
|
17
|
+
@filtered_scope = search(@filtered_scope, current_query) if current_query.present?
|
|
25
18
|
|
|
26
19
|
# Use inject
|
|
27
20
|
active_filters.each do |filter|
|
|
@@ -36,7 +29,13 @@ module Mensa
|
|
|
36
29
|
return @ordered_scope if @ordered_scope
|
|
37
30
|
|
|
38
31
|
@ordered_scope = filtered_scope
|
|
39
|
-
@ordered_scope =
|
|
32
|
+
@ordered_scope = if effective_order.present?
|
|
33
|
+
@ordered_scope.reorder(effective_order)
|
|
34
|
+
elsif search_order_clause.present?
|
|
35
|
+
@ordered_scope.reorder(Arel.sql(search_order_clause))
|
|
36
|
+
else
|
|
37
|
+
@ordered_scope.reorder(nil)
|
|
38
|
+
end
|
|
40
39
|
|
|
41
40
|
@ordered_scope
|
|
42
41
|
end
|
|
@@ -45,6 +44,8 @@ module Mensa
|
|
|
45
44
|
def selected_scope
|
|
46
45
|
return @selected_scope if @selected_scope
|
|
47
46
|
|
|
47
|
+
ensure_internal_columns_for_joined_associations
|
|
48
|
+
|
|
48
49
|
@selected_scope = ordered_scope
|
|
49
50
|
@selected_scope = @selected_scope.select([:id] + columns.filter_map(&:attribute))
|
|
50
51
|
|
|
@@ -73,16 +74,16 @@ module Mensa
|
|
|
73
74
|
# (even with blank values), use only those — blank means "explicitly no sort".
|
|
74
75
|
# Falls back to the view/config default only when no order params were sent.
|
|
75
76
|
def effective_order
|
|
76
|
-
result =
|
|
77
|
+
result = current_order_provided? ? (current_order || {}) : (config[:order] || {})
|
|
77
78
|
result = result.symbolize_keys.compact_blank.transform_values(&:to_sym)
|
|
78
|
-
result.transform_keys { column(
|
|
79
|
+
result.transform_keys { |column_name| column(column_name).attribute_for_condition }
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
# Builds an order hash for URL generation. Merges current order with overrides;
|
|
82
83
|
# nil values become "" so they appear in the URL as order[col]= (which tells
|
|
83
84
|
# the server the user explicitly cleared that column's sort direction).
|
|
84
85
|
def order_hash(new_params = {})
|
|
85
|
-
base =
|
|
86
|
+
base = current_order&.symbolize_keys || config[:order]&.symbolize_keys || {}
|
|
86
87
|
merged = base.merge(new_params.symbolize_keys)
|
|
87
88
|
merged.transform_values { |v| v.nil? ? "" : v.to_sym }
|
|
88
89
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mensa
|
|
4
|
+
module Search
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def search(record_scope, query)
|
|
10
|
+
return record_scope if query.blank?
|
|
11
|
+
return record_scope unless record_scope.is_a?(ActiveRecord::Relation)
|
|
12
|
+
|
|
13
|
+
searchable_attributes = columns.filter_map(&:attribute_for_condition).uniq
|
|
14
|
+
return record_scope if searchable_attributes.empty?
|
|
15
|
+
|
|
16
|
+
@search_order_clause = nil
|
|
17
|
+
|
|
18
|
+
fuzzy_search?(record_scope) ? fuzzy_search(record_scope, searchable_attributes, query.to_s) : basic_search(record_scope, searchable_attributes, query.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def basic_search(record_scope, searchable_attributes, query)
|
|
22
|
+
sanitized_query = ActiveRecord::Base.sanitize_sql_like(query)
|
|
23
|
+
conditions = searchable_attributes.map do |attribute|
|
|
24
|
+
"CAST((#{attribute}) AS text) ILIKE :term"
|
|
25
|
+
end.join(" OR ")
|
|
26
|
+
|
|
27
|
+
record_scope.where(conditions, term: "%#{sanitized_query}%")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fuzzy_search(record_scope, searchable_attributes, query)
|
|
31
|
+
sanitized_query = ActiveRecord::Base.sanitize_sql_like(query)
|
|
32
|
+
compare_quoted = record_scope.model.connection.quote(query)
|
|
33
|
+
text_expression = searchable_text_expression(searchable_attributes)
|
|
34
|
+
score_sql = "similarity(#{text_expression}, #{compare_quoted})"
|
|
35
|
+
|
|
36
|
+
@search_order_clause = "#{score_sql} DESC"
|
|
37
|
+
|
|
38
|
+
record_scope
|
|
39
|
+
.select(Arel.sql("#{score_sql} AS mensa_search_score"))
|
|
40
|
+
.where("#{text_expression} % :query OR #{text_expression} ILIKE :term", query: query, term: "%#{sanitized_query}%")
|
|
41
|
+
.order(Arel.sql(@search_order_clause))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def searchable_text_expression(searchable_attributes)
|
|
45
|
+
fields = searchable_attributes.map do |attribute|
|
|
46
|
+
"COALESCE(CAST((#{attribute}) AS text), '')"
|
|
47
|
+
end.join(", ")
|
|
48
|
+
|
|
49
|
+
"CONCAT_WS(' ', #{fields})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def search_order_clause
|
|
53
|
+
@search_order_clause
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fuzzy_search?(record_scope)
|
|
57
|
+
Mensa.config.search == :fuzzy && pg_trgm_enabled?(record_scope)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pg_trgm_enabled?(record_scope)
|
|
61
|
+
connection = record_scope.model.connection
|
|
62
|
+
return false unless connection.adapter_name.to_s.downcase.include?("postgres")
|
|
63
|
+
return connection.extension_enabled?("pg_trgm") if connection.respond_to?(:extension_enabled?)
|
|
64
|
+
|
|
65
|
+
connection.select_value("SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm' LIMIT 1").present?
|
|
66
|
+
rescue
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -37,6 +37,29 @@
|
|
|
37
37
|
</label>
|
|
38
38
|
</fieldset>
|
|
39
39
|
|
|
40
|
+
<fieldset class="mensa-table__export-dialog__fieldset">
|
|
41
|
+
<legend class="mensa-table__export-dialog__section-title"><%= t("mensa.exports.repeat_section", default: "Repeat") %></legend>
|
|
42
|
+
<label class="mensa-table__export-dialog__option">
|
|
43
|
+
<input type="radio" name="repeat_mode" value="once" checked data-action="change->mensa-table#toggleExportRepeat">
|
|
44
|
+
<span><%= t("mensa.exports.repeat_once", default: "Only once") %></span>
|
|
45
|
+
</label>
|
|
46
|
+
<label class="mensa-table__export-dialog__option">
|
|
47
|
+
<input type="radio" name="repeat_mode" value="repeating" data-action="change->mensa-table#toggleExportRepeat">
|
|
48
|
+
<span><%= t("mensa.exports.repeat_repeating", default: "Repeating") %></span>
|
|
49
|
+
</label>
|
|
50
|
+
|
|
51
|
+
<div class="mensa-table__export-dialog__repeat-options" hidden data-mensa-table-repeat-options>
|
|
52
|
+
<select name="repeat" class="mensa-table__export-dialog__select">
|
|
53
|
+
<option value="daily"><%= t("mensa.exports.repeat_intervals.daily", default: "daily") %></option>
|
|
54
|
+
<option value="weekly"><%= t("mensa.exports.repeat_intervals.weekly", default: "weekly") %></option>
|
|
55
|
+
<option value="monthly"><%= t("mensa.exports.repeat_intervals.monthly", default: "monthly") %></option>
|
|
56
|
+
<option value="quarterly"><%= t("mensa.exports.repeat_intervals.quarterly", default: "quarterly") %></option>
|
|
57
|
+
<option value="bi-yearly"><%= t("mensa.exports.repeat_intervals.bi-yearly", default: "bi-yearly") %></option>
|
|
58
|
+
<option value="yearly"><%= t("mensa.exports.repeat_intervals.yearly", default: "yearly") %></option>
|
|
59
|
+
</select>
|
|
60
|
+
</div>
|
|
61
|
+
</fieldset>
|
|
62
|
+
|
|
40
63
|
<div class="mensa-table__export-dialog__actions">
|
|
41
64
|
<button class="mensa-table__export-dialog__button mensa-table__export-dialog__button--secondary" type="button" data-action="mensa-table#cancelExport">
|
|
42
65
|
<%= t("mensa.exports.cancel", default: "Cancel") %>
|
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
<ul class="mensa-table__export-dialog__list">
|
|
7
7
|
<% exports.each do |export| %>
|
|
8
8
|
<li class="mensa-table__export-dialog__item">
|
|
9
|
+
<%= button_to mensa.table_export_path(export.table_name, export),
|
|
10
|
+
method: :delete,
|
|
11
|
+
class: "mensa-table__export-dialog__delete",
|
|
12
|
+
form_class: "mensa-table__export-dialog__delete-form",
|
|
13
|
+
aria: {label: t("mensa.exports.delete", default: "Delete export") } do %>
|
|
14
|
+
<i class="fa-solid fa-trash"></i>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
9
17
|
<div class="mensa-table__export-dialog__item-info">
|
|
10
18
|
<span class="mensa-table__export-dialog__item-name"><%= export.filename.presence || t("mensa.exports.item_name", default: "%{table} export", table: export.table_name.to_s.humanize) %></span>
|
|
19
|
+
<% if export.repeating? %>
|
|
20
|
+
<span class="mensa-table__export-dialog__item-repeat"><%= export.repeat_label %></span>
|
|
21
|
+
<% end %>
|
|
11
22
|
<span class="mensa-table__export-dialog__item-meta"><%= export.created_at.strftime("%Y-%m-%d %H:%M") %></span>
|
|
12
23
|
</div>
|
|
13
24
|
<div class="mensa-table__export-dialog__item-action">
|
|
@@ -18,6 +29,8 @@
|
|
|
18
29
|
<% end %>
|
|
19
30
|
<% elsif export.failed? %>
|
|
20
31
|
<span class="mensa-table__export-dialog__status mensa-table__export-dialog__status--failed"><%= t("mensa.exports.failed", default: "Failed") %></span>
|
|
32
|
+
<% elsif export.completed? %>
|
|
33
|
+
<span class="mensa-table__export-dialog__status mensa-table__export-dialog__status--pending"><%= t("mensa.exports.waiting", default: "Waiting") %></span>
|
|
21
34
|
<% else %>
|
|
22
35
|
<span class="mensa-table__export-dialog__status mensa-table__export-dialog__status--pending">
|
|
23
36
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<turbo-frame id="<%= @frame_id %>">
|
|
2
|
+
<div class="mensa-empty-state">
|
|
3
|
+
<div class="mensa-empty-state__icon">
|
|
4
|
+
<i class="fa-solid fa-circle-exclamation"></i>
|
|
5
|
+
</div>
|
|
6
|
+
<h3 class="mensa-empty-state__title">Could not load table</h3>
|
|
7
|
+
<p class="mensa-empty-state__subtitle"><%= @error_message %></p>
|
|
8
|
+
</div>
|
|
9
|
+
</turbo-frame>
|
data/config/locales/en.yml
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
en:
|
|
2
2
|
mensa:
|
|
3
|
+
actions: Actions
|
|
4
|
+
save: Save
|
|
5
|
+
update_view: Update view
|
|
6
|
+
save_view: Save as new view
|
|
7
|
+
save_as_new_view: Save as new view
|
|
3
8
|
operators:
|
|
4
9
|
is: is
|
|
5
10
|
isnt: isn't
|
|
@@ -10,7 +15,9 @@ en:
|
|
|
10
15
|
lt: less than
|
|
11
16
|
gteq: greater than or equal to
|
|
12
17
|
lteq: less than or equal to
|
|
18
|
+
is_empty: is empty
|
|
13
19
|
add_filter:
|
|
20
|
+
add: Add filter
|
|
14
21
|
component:
|
|
15
22
|
add_filter: Add filter
|
|
16
23
|
filter_pill_list:
|
|
@@ -19,6 +26,7 @@ en:
|
|
|
19
26
|
search_only: Search
|
|
20
27
|
search:
|
|
21
28
|
component:
|
|
29
|
+
search: Search and filter
|
|
22
30
|
search_in: Search in %{view}
|
|
23
31
|
cancel: Cancel
|
|
24
32
|
save: Save
|
|
@@ -34,8 +42,22 @@ en:
|
|
|
34
42
|
available_downloads: Available downloads
|
|
35
43
|
empty: You have no downloads yet. Create one below.
|
|
36
44
|
item_name: "%{table} export"
|
|
45
|
+
repeats_with_interval: "Repeats %{interval}"
|
|
46
|
+
repeat_section: Repeat
|
|
47
|
+
repeat: Repeat
|
|
48
|
+
repeat_once: "Only once"
|
|
49
|
+
repeat_repeating: Repeating
|
|
50
|
+
repeat_intervals:
|
|
51
|
+
daily: daily
|
|
52
|
+
weekly: weekly
|
|
53
|
+
monthly: monthly
|
|
54
|
+
quarterly: quarterly
|
|
55
|
+
bi-yearly: bi-yearly
|
|
56
|
+
yearly: yearly
|
|
37
57
|
download: Download
|
|
58
|
+
delete: Delete export
|
|
38
59
|
processing: Preparing…
|
|
60
|
+
waiting: Waiting
|
|
39
61
|
failed: Failed
|
|
40
62
|
new_export: New export
|
|
41
63
|
scope_all: All records (matching current filters)
|
data/config/locales/nl.yml
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
nl:
|
|
2
2
|
mensa:
|
|
3
|
+
actions: Acties
|
|
4
|
+
save: Bewaar
|
|
5
|
+
update_view: Bewaar weergave
|
|
6
|
+
save_view: Bewaar als nieuwe weergave
|
|
7
|
+
save_as_new_view: Bewaar als nieuwe weergave
|
|
3
8
|
operators:
|
|
4
9
|
is: is
|
|
5
10
|
isnt: is niet
|
|
@@ -10,7 +15,9 @@ nl:
|
|
|
10
15
|
lt: kleiner dan
|
|
11
16
|
gteq: groter dan of gelijk aan
|
|
12
17
|
lteq: kleiner dan of gelijk aan
|
|
18
|
+
is_empty: is leeg
|
|
13
19
|
add_filter:
|
|
20
|
+
add: Filter toevoegen
|
|
14
21
|
component:
|
|
15
22
|
add_filter: Filter toevoegen
|
|
16
23
|
filter_pill_list:
|
|
@@ -35,8 +42,22 @@ nl:
|
|
|
35
42
|
available_downloads: Beschikbare downloads
|
|
36
43
|
empty: Je hebt nog geen downloads. Maak er hieronder een aan.
|
|
37
44
|
item_name: "%{table} export"
|
|
45
|
+
repeats_with_interval: "Herhaalt %{interval}"
|
|
46
|
+
repeat_section: Herhalen
|
|
47
|
+
repeat: Herhalen
|
|
48
|
+
repeat_once: "Eenmalig"
|
|
49
|
+
repeat_repeating: Herhalend
|
|
50
|
+
repeat_intervals:
|
|
51
|
+
daily: dagelijks
|
|
52
|
+
weekly: wekelijks
|
|
53
|
+
monthly: maandelijks
|
|
54
|
+
quarterly: per kwartaal
|
|
55
|
+
bi-yearly: halfjaarlijks
|
|
56
|
+
yearly: jaarlijks
|
|
38
57
|
download: Downloaden
|
|
58
|
+
delete: Export verwijderen
|
|
39
59
|
processing: Bezig…
|
|
60
|
+
waiting: Wachtend
|
|
40
61
|
failed: Mislukt
|
|
41
62
|
new_export: Nieuwe export
|
|
42
63
|
scope_all: Alle records (volgens huidige filters)
|