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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +6 -2
  3. data/.devcontainer/compose.yaml +1 -1
  4. data/.devcontainer/devcontainer.json +31 -29
  5. data/.devcontainer/postCreate.sh +8 -0
  6. data/.devcontainer/postStart.sh +9 -0
  7. data/.gitignore +3 -1
  8. data/.zed/tasks.json +12 -0
  9. data/Gemfile.lock +155 -153
  10. data/Procfile +1 -1
  11. data/README.md +85 -60
  12. data/app/assets/stylesheets/mensa/application.css +14 -11
  13. data/app/components/mensa/add_filter/component.css +110 -5
  14. data/app/components/mensa/add_filter/component.html.slim +10 -12
  15. data/app/components/mensa/add_filter/component.rb +7 -1
  16. data/app/components/mensa/add_filter/component_controller.js +697 -83
  17. data/app/components/mensa/cell/component.css +9 -0
  18. data/app/components/mensa/column_customizer/component.css +40 -0
  19. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  20. data/app/components/mensa/column_customizer/component.rb +13 -0
  21. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  22. data/app/components/mensa/control_bar/component.css +127 -4
  23. data/app/components/mensa/control_bar/component.html.slim +41 -14
  24. data/app/components/mensa/control_bar/component.rb +0 -4
  25. data/app/components/mensa/empty_state/component.css +20 -0
  26. data/app/components/mensa/empty_state/component.html.slim +7 -0
  27. data/app/components/mensa/empty_state/component.rb +18 -0
  28. data/app/components/mensa/filter_pill/component.css +23 -0
  29. data/app/components/mensa/filter_pill/component.html.slim +9 -6
  30. data/app/components/mensa/filter_pill/component.rb +9 -0
  31. data/app/components/mensa/filter_pill/component_controller.js +50 -10
  32. data/app/components/mensa/filter_pill_list/component.css +58 -9
  33. data/app/components/mensa/filter_pill_list/component.html.slim +11 -8
  34. data/app/components/mensa/filter_pill_list/component_controller.js +747 -48
  35. data/app/components/mensa/header/component.css +41 -43
  36. data/app/components/mensa/header/component.html.slim +7 -7
  37. data/app/components/mensa/row_action/component.html.slim +2 -2
  38. data/app/components/mensa/search/component.css +68 -9
  39. data/app/components/mensa/search/component.html.slim +19 -15
  40. data/app/components/mensa/search/component_controller.js +39 -49
  41. data/app/components/mensa/selection/component_controller.js +147 -0
  42. data/app/components/mensa/table/component.css +28 -0
  43. data/app/components/mensa/table/component.html.slim +9 -6
  44. data/app/components/mensa/table/component.rb +1 -0
  45. data/app/components/mensa/table/component_controller.js +524 -88
  46. data/app/components/mensa/table_row/component.css +6 -0
  47. data/app/components/mensa/table_row/component.html.slim +8 -3
  48. data/app/components/mensa/view/component.css +97 -29
  49. data/app/components/mensa/view/component.html.slim +23 -10
  50. data/app/components/mensa/view/component.rb +5 -0
  51. data/app/components/mensa/views/component.css +106 -13
  52. data/app/components/mensa/views/component.html.slim +51 -17
  53. data/app/components/mensa/views/component_controller.js +245 -20
  54. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  55. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  56. data/app/controllers/mensa/tables/filters_controller.rb +4 -1
  57. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  58. data/app/controllers/mensa/tables_controller.rb +3 -6
  59. data/app/helpers/mensa/application_helper.rb +4 -0
  60. data/app/javascript/mensa/application.js +2 -2
  61. data/app/javascript/mensa/controllers/index.js +13 -4
  62. data/app/jobs/mensa/export_job.rb +77 -84
  63. data/app/models/mensa/export.rb +93 -0
  64. data/app/tables/mensa/base.rb +103 -12
  65. data/app/tables/mensa/batch_action.rb +27 -0
  66. data/app/tables/mensa/cell.rb +15 -0
  67. data/app/tables/mensa/column.rb +15 -2
  68. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  69. data/app/tables/mensa/config/column_dsl.rb +1 -0
  70. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  71. data/app/tables/mensa/config/render_dsl.rb +1 -1
  72. data/app/tables/mensa/config/table_dsl.rb +12 -5
  73. data/app/tables/mensa/config/view_dsl.rb +2 -0
  74. data/app/tables/mensa/config_readers.rb +20 -1
  75. data/app/tables/mensa/filter.rb +86 -3
  76. data/app/tables/mensa/scope.rb +24 -12
  77. data/app/views/mensa/exports/_badge.html.slim +5 -0
  78. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  79. data/app/views/mensa/exports/_list.html.slim +29 -0
  80. data/app/views/mensa/tables/filters/show.turbo_stream.slim +35 -8
  81. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  82. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  83. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  84. data/config/locales/en.yml +44 -0
  85. data/config/locales/nl.yml +45 -0
  86. data/config/routes.rb +7 -0
  87. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  88. data/docs/columns.png +0 -0
  89. data/docs/export.png +0 -0
  90. data/docs/filters.png +0 -0
  91. data/docs/table.png +0 -0
  92. data/lib/mensa/configuration.rb +33 -12
  93. data/lib/mensa/engine.rb +7 -2
  94. data/lib/mensa/version.rb +1 -1
  95. data/mensa.gemspec +2 -1
  96. data/mise.toml +8 -0
  97. data/package-lock.json +0 -7
  98. metadata +50 -8
@@ -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 ||= columns.select(&:visible?).reject(&:internal?)
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
- (config[:views] || {}).keys.map { |view_name| Mensa::SystemView.new(view_name, config: config.dig(:views, view_name), table: self) }
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] || {}).map { |column_name, filter_config| Mensa::Filter.new(column: column(column_name), config: filter_config, table: self) }
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: nil)
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(name, order: order_hash(order), turbo_frame_id: turbo_frame_id, table_view_id: table_view_id)
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, Current.user])
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
@@ -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
@@ -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)&.to_sym
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 => :asc}), link_attributes: {"data-turbo-frame": "_self"}
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
@@ -15,6 +15,7 @@ module Mensa::Config
15
15
  # Mensa doesn't select the whole records, to only select what we need
16
16
  option :internal, default: false
17
17
  option :method
18
+ option :type
18
19
 
19
20
  option :visible, default: true
20
21
  option :render, dsl: Mensa::Config::RenderDsl
@@ -2,9 +2,12 @@ module Mensa::Config
2
2
  class FilterDsl
3
3
  include DslLogic
4
4
 
5
- option :operator, default: :equals
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
@@ -3,6 +3,6 @@ module Mensa::Config
3
3
  include DslLogic
4
4
 
5
5
  option :html
6
- option :xlsx
6
+ option :csv
7
7
  end
8
8
  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
- # xlsx # Mensa::XlsxRenderer::Default
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
@@ -5,5 +5,7 @@ module Mensa::Config
5
5
  option :name
6
6
  option :description
7
7
  option :filter, dsl_hash: Mensa::Config::FilterDsl
8
+ option :column_order
9
+ option :hidden_columns
8
10
  end
9
11
  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
- @definition ||= dsl_class.new(name, &block).config
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
 
@@ -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
- "#{column.human_name}: #{value}"
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 :equals
43
- record_scope.where(column.attribute_for_condition => normalize(value))
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)
@@ -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
- @filtered_scope = @filtered_scope.basic_search(params[:query]) if params[:query]
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(order_hash)
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
- # Though this works, perhaps moving this in column(s) is nicer
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
- (params[:order] || config[:order]).merge(new_params.symbolize_keys)
74
- .compact_blank
75
- .transform_values { |value| value.to_sym }
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…")