mensa 0.6.13 → 0.6.14

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.shipkit.yml +1 -1
  3. data/Gemfile.lock +1 -1
  4. data/README.md +38 -9
  5. data/app/assets/stylesheets/mensa/application.css +2 -0
  6. data/app/components/mensa/cell/component.html.erb +1 -1
  7. data/app/components/mensa/control_bar/component.html.erb +1 -0
  8. data/app/components/mensa/filter_pill_list/component_controller.js +55 -1
  9. data/app/components/mensa/group_by/component.css +49 -0
  10. data/app/components/mensa/group_by/component.html.erb +41 -0
  11. data/app/components/mensa/group_by/component.rb +27 -0
  12. data/app/components/mensa/group_by/component_controller.js +168 -0
  13. data/app/components/mensa/group_header/component.css +39 -0
  14. data/app/components/mensa/group_header/component.html.erb +34 -0
  15. data/app/components/mensa/group_header/component.rb +40 -0
  16. data/app/components/mensa/group_header/component_controller.js +63 -0
  17. data/app/components/mensa/table/component_controller.js +30 -1
  18. data/app/components/mensa/table_row/component.html.erb +1 -1
  19. data/app/components/mensa/table_row/component.rb +9 -1
  20. data/app/components/mensa/view/component.html.erb +17 -3
  21. data/app/controllers/mensa/tables/exports_controller.rb +1 -0
  22. data/app/controllers/mensa/tables/views_controller.rb +1 -1
  23. data/app/controllers/mensa/tables_controller.rb +3 -1
  24. data/app/javascript/mensa/controllers/index.js +6 -0
  25. data/app/tables/mensa/base.rb +14 -1
  26. data/app/tables/mensa/cell.rb +31 -4
  27. data/app/tables/mensa/column.rb +18 -0
  28. data/app/tables/mensa/config/column_dsl.rb +10 -0
  29. data/app/tables/mensa/config/table_dsl.rb +5 -0
  30. data/app/tables/mensa/config/view_dsl.rb +2 -0
  31. data/app/tables/mensa/filter.rb +17 -14
  32. data/app/tables/mensa/group.rb +47 -0
  33. data/app/tables/mensa/grouping.rb +94 -0
  34. data/app/tables/mensa/row.rb +5 -0
  35. data/app/tables/mensa/scope.rb +20 -12
  36. data/config/locales/en.yml +12 -0
  37. data/config/locales/nl.yml +12 -0
  38. data/lib/mensa/configuration.rb +6 -0
  39. data/lib/mensa/version.rb +1 -1
  40. metadata +12 -2
@@ -0,0 +1,63 @@
1
+ import ApplicationController from "mensa/controllers/application_controller";
2
+
3
+ // Collapses and expands groups in a grouped table. Collapsed groups are
4
+ // remembered per table and group column.
5
+ export default class GroupsController extends ApplicationController {
6
+ static targets = ["header", "row"];
7
+ static values = { storageKey: String };
8
+
9
+ connect() {
10
+ super.connect();
11
+ this.collapsed = new Set(this._load());
12
+ this._apply();
13
+ }
14
+
15
+ toggle(event) {
16
+ const key = event.currentTarget.dataset.groupKey;
17
+ if (this.collapsed.has(key)) {
18
+ this.collapsed.delete(key);
19
+ } else {
20
+ this.collapsed.add(key);
21
+ }
22
+ this._save();
23
+ this._apply();
24
+ }
25
+
26
+ _apply() {
27
+ this.headerTargets.forEach((header) => {
28
+ header.setAttribute(
29
+ "aria-expanded",
30
+ (!this.collapsed.has(header.dataset.groupKey)).toString(),
31
+ );
32
+ });
33
+ this.rowTargets.forEach((row) => {
34
+ row.classList.toggle(
35
+ "hidden",
36
+ this.collapsed.has(row.dataset.groupKey),
37
+ );
38
+ });
39
+ }
40
+
41
+ _load() {
42
+ try {
43
+ return JSON.parse(
44
+ window.localStorage.getItem(this.storageKeyValue) || "[]",
45
+ );
46
+ } catch (e) {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ _save() {
52
+ try {
53
+ if (this.collapsed.size > 0) {
54
+ window.localStorage.setItem(
55
+ this.storageKeyValue,
56
+ JSON.stringify([...this.collapsed]),
57
+ );
58
+ } else {
59
+ window.localStorage.removeItem(this.storageKeyValue);
60
+ }
61
+ } catch (e) {}
62
+ }
63
+ }
@@ -262,6 +262,8 @@ export default class TableComponentController extends ApplicationController {
262
262
  order: state.order,
263
263
  column_order: state.column_order,
264
264
  hidden_columns: state.hidden_columns,
265
+ group_by: state.group_by,
266
+ aggregates: state.aggregates,
265
267
  turbo_frame_id: this.hasTurboFrameTarget
266
268
  ? this.turboFrameTarget.id
267
269
  : null,
@@ -323,6 +325,8 @@ export default class TableComponentController extends ApplicationController {
323
325
  order: state.order,
324
326
  column_order: state.column_order,
325
327
  hidden_columns: state.hidden_columns,
328
+ group_by: state.group_by,
329
+ aggregates: state.aggregates,
326
330
  turbo_frame_id: this.hasTurboFrameTarget
327
331
  ? this.turboFrameTarget.id
328
332
  : null,
@@ -357,10 +361,34 @@ export default class TableComponentController extends ApplicationController {
357
361
  order: outlet.loadOrder(),
358
362
  column_order: outlet.loadColumnOrder(),
359
363
  hidden_columns: outlet.loadHiddenColumns(),
364
+ group_by: this.groupState?.group_by ?? "",
365
+ aggregates: this.groupState?.aggregates ?? {},
360
366
  };
361
367
  }
362
368
 
363
- viewTargetConnected() {}
369
+ // The rendered view carries the grouping the server applied, keep the
370
+ // group-by popover in sync with it (e.g. after selecting a view).
371
+ viewTargetConnected(element) {
372
+ let aggregates = {};
373
+ try {
374
+ aggregates = JSON.parse(element.dataset.aggregates || "{}");
375
+ } catch (e) {}
376
+ this.groupState = {
377
+ group_by: element.dataset.groupBy || "",
378
+ aggregates,
379
+ };
380
+
381
+ const groupByElement = this.element.querySelector(
382
+ '[data-controller~="mensa-group-by"]',
383
+ );
384
+ const groupBy =
385
+ groupByElement &&
386
+ this.application.getControllerForElementAndIdentifier(
387
+ groupByElement,
388
+ "mensa-group-by",
389
+ );
390
+ if (groupBy) groupBy.sync(this.groupState.group_by, aggregates);
391
+ }
364
392
 
365
393
  filterListTargetConnected(element) {
366
394
  // The filter bar is always visible in the new design — nothing to toggle.
@@ -575,6 +603,7 @@ export default class TableComponentController extends ApplicationController {
575
603
  order: state.order,
576
604
  column_order: state.column_order,
577
605
  hidden_columns: state.hidden_columns,
606
+ group_by: state.group_by,
578
607
  }),
579
608
  contentType: "application/json",
580
609
  responseKind: "turbo-stream",
@@ -1,4 +1,4 @@
1
- <%= content_tag :tr, **row.link_attributes do %>
1
+ <%= content_tag :tr, **row_attributes do %>
2
2
  <% if table.batch_actions? %>
3
3
  <td class="mensa-table__checkbox-col" data-action="click->mensa-selection#stopPropagation">
4
4
  <input class="mensa-table__select-all" type="checkbox" value="<%= row.record.id %>" data-action="change->mensa-selection#toggleRow" data-mensa-selection-target="rowCheckbox">
@@ -9,10 +9,18 @@ module Mensa
9
9
 
10
10
  attr_reader :table
11
11
  attr_reader :row
12
+ attr_reader :group
12
13
 
13
- def initialize(table:, row:)
14
+ def initialize(table:, row:, group: nil)
14
15
  @table = table
15
16
  @row = row
17
+ @group = group
18
+ end
19
+
20
+ def row_attributes
21
+ return row.link_attributes unless group
22
+
23
+ row.link_attributes.deep_merge(data: {mensa_groups_target: "row", group_key: group.key})
16
24
  end
17
25
  end
18
26
  end
@@ -1,4 +1,6 @@
1
- <div data-mensa-table-target="view">
1
+ <div data-mensa-table-target="view"
2
+ data-group-by="<%= table.group_column&.name %>"
3
+ data-aggregates="<%= table.current_aggregates.to_json %>">
2
4
  <div class="overflow-y-auto relative"
3
5
  data-mensa-table-target="scrollContainer"
4
6
  data-controller="mensa-selection"
@@ -16,7 +18,11 @@
16
18
  </div>
17
19
  </div>
18
20
  <% end %>
19
- <table class="w-full">
21
+ <table class="w-full"
22
+ <% if table.grouped? %>
23
+ data-controller="mensa-groups"
24
+ data-mensa-groups-storage-key-value="<%= "mensa:collapsed_groups:#{table.storage_key}:#{table.group_column.name}" %>"
25
+ <% end %>>
20
26
  <% if table.show_header? %>
21
27
  <thead>
22
28
  <tr>
@@ -38,7 +44,15 @@
38
44
  </thead>
39
45
  <% end %>
40
46
  <tbody>
41
- <%= render(Mensa::TableRow::Component.with_collection(table.rows, table: table)) %>
47
+ <% if table.grouped? %>
48
+ <% table.rows.chunk_while { |a, b| a.group_value == b.group_value }.each do |rows| %>
49
+ <% group = table.group_for(rows.first) %>
50
+ <%= render Mensa::GroupHeader::Component.new(table: table, group: group) %>
51
+ <%= render(Mensa::TableRow::Component.with_collection(rows, table: table, group: group)) %>
52
+ <% end %>
53
+ <% else %>
54
+ <%= render(Mensa::TableRow::Component.with_collection(table.rows, table: table)) %>
55
+ <% end %>
42
56
  </tbody>
43
57
  </table>
44
58
  <% if table.pagy_details&.count == 0 %>
@@ -128,6 +128,7 @@ module Mensa
128
128
  :query,
129
129
  :page,
130
130
  :table_view_id,
131
+ :group_by,
131
132
  order: {},
132
133
  filters: {},
133
134
  column_order: [],
@@ -95,7 +95,7 @@ module Mensa
95
95
  # show (query, ordering and filters), so a saved view can be replayed by
96
96
  # merging this hash back into the request params.
97
97
  def view_config
98
- config = params.permit(:query, order: {}, column_order: [], hidden_columns: []).to_h
98
+ config = params.permit(:query, :group_by, order: {}, column_order: [], hidden_columns: [], aggregates: {}).to_h
99
99
  config[:filters] = params[:filters]&.to_unsafe_h || {}
100
100
  config
101
101
  end
@@ -2,8 +2,10 @@ module Mensa
2
2
  class TablesController < ApplicationController
3
3
  def show
4
4
  filters_provided = params.key?(:filters)
5
- config = params.permit(:format, :query, :id, :page, :table_view_id, :turbo_frame_id, order: {}, column_order: [], hidden_columns: [], params: {}, filters: {}).to_h
5
+ config = params.permit(:format, :query, :id, :page, :table_view_id, :turbo_frame_id, :group_by, order: {}, column_order: [], hidden_columns: [], params: {}, filters: {}, aggregates: {}).to_h
6
6
  config[:filters] = {} if filters_provided && !config.key?(:filters)
7
+ # aggregates= (blank) means none, also when the view has some
8
+ config[:aggregates] = {} if params.key?(:aggregates) && !config.key?(:aggregates)
7
9
 
8
10
  @table = Mensa.for_name(params[:id], config)
9
11
 
@@ -30,6 +30,12 @@ application.register("mensa-selection", SelectionComponentController);
30
30
  import ColumnCustomizerController from "mensa/components/column_customizer/component_controller";
31
31
  application.register("mensa-column-customizer", ColumnCustomizerController);
32
32
 
33
+ import GroupByComponentController from "mensa/components/group_by/component_controller";
34
+ application.register("mensa-group-by", GroupByComponentController);
35
+
36
+ import GroupHeaderComponentController from "mensa/components/group_header/component_controller";
37
+ application.register("mensa-groups", GroupHeaderComponentController);
38
+
33
39
  import CopyableComponentController from "mensa/components/copyable/component_controller";
34
40
  application.register("mensa-copyable", CopyableComponentController);
35
41
 
@@ -147,7 +147,9 @@ module Mensa
147
147
  table_view_id: table_view_id,
148
148
  page: page,
149
149
  column_order: column_order,
150
- hidden_columns: hidden_columns
150
+ hidden_columns: hidden_columns,
151
+ group_by: group_by_param,
152
+ aggregates: aggregates_param
151
153
  }.compact.to_query
152
154
 
153
155
  query.present? ? "#{path}?#{query}" : path
@@ -237,10 +239,21 @@ module Mensa
237
239
  context[:table_view_id] = current_table_view_id if config.key?(:table_view_id) && !current_table_view_id.nil?
238
240
  context[:column_order] = current_column_order if config.key?(:column_order) && !current_column_order.nil?
239
241
  context[:hidden_columns] = current_hidden_columns if config.key?(:hidden_columns) && !current_hidden_columns.nil?
242
+ context[:group_by] = current_group_by if grouped?
240
243
 
241
244
  context
242
245
  end
243
246
 
247
+ # Grouping in a URL, blank means "explicitly not grouped" so it overrides
248
+ # the view's default
249
+ def group_by_param
250
+ current_group_by.to_s unless config[:group_by].nil?
251
+ end
252
+
253
+ def aggregates_param
254
+ current_aggregates.presence || ("" unless config[:aggregates].nil?)
255
+ end
256
+
244
257
  private
245
258
 
246
259
  def ensure_internal_columns_for_joined_associations
@@ -10,16 +10,45 @@ module Mensa
10
10
 
11
11
  attr_reader :column, :row
12
12
 
13
- def initialize(row:, column:)
13
+ # Pass a value to format one that doesn't come from a row, e.g. a group's
14
+ def initialize(row:, column:, value: nil)
14
15
  @row = row
15
16
  @column = column
17
+ @value = value
18
+ @value_given = row.nil?
16
19
  end
17
20
 
18
21
  def value
19
- @value ||= row.value(column)
22
+ return @value if @value_given
23
+
24
+ @value_given = true
25
+ @value = row.value(column)
26
+ end
27
+
28
+ # The value in the given format, without custom render blocks or a <td>
29
+ def formatted(format = :html)
30
+ send(:"to_#{format}")
20
31
  end
21
32
 
33
+ # Renders the cell's content in the given format. The :html format
34
+ # includes the surrounding <td>; when a custom html render block returns
35
+ # its own td (e.g. content_tag(:td, ..., class: ...)), that one is used.
22
36
  def render(format)
37
+ content = render_content(format)
38
+ return content unless format.to_sym == :html
39
+ return content if td?(content)
40
+
41
+ content_tag(:td, content)
42
+ end
43
+
44
+ private
45
+
46
+ # Only HTML-safe output counts, so plain strings are still escaped.
47
+ def td?(content)
48
+ content.html_safe? && content.to_s.lstrip.match?(/\A<td[\s>]/i)
49
+ end
50
+
51
+ def render_content(format)
23
52
  proc = column.config.dig(:render, format.to_sym)
24
53
  if proc
25
54
  row.table.original_view_context.instance_exec(row.record, &proc)
@@ -28,8 +57,6 @@ module Mensa
28
57
  end
29
58
  end
30
59
 
31
- private
32
-
33
60
  def to_html
34
61
  case value
35
62
  when NilClass
@@ -21,6 +21,8 @@ module Mensa
21
21
  config_reader :method # When a method needs to be called on the model, slow!
22
22
  config_reader :format
23
23
 
24
+ AGGREGATE_FUNCTIONS = %i[count sum min max].freeze
25
+
24
26
  def sort_direction
25
27
  value = table.config.dig(:order, name)
26
28
  value.presence&.to_sym
@@ -78,6 +80,22 @@ module Mensa
78
80
  end
79
81
  end
80
82
 
83
+ # Grouping and aggregating happen in SQL, so the column needs an attribute
84
+ def groupable?
85
+ config[:groupable] == true && attribute_for_condition.present?
86
+ end
87
+
88
+ # The aggregate functions the user can choose for this column
89
+ def aggregates
90
+ return [] if attribute_for_condition.blank?
91
+
92
+ Array(config[:aggregates]).map(&:to_sym) & AGGREGATE_FUNCTIONS
93
+ end
94
+
95
+ def aggregatable?
96
+ aggregates.any?
97
+ end
98
+
81
99
  # Returns true if the column supports filtering
82
100
  def filter?
83
101
  config[:filter] != false
@@ -19,7 +19,17 @@ module Mensa::Config
19
19
  option :format, default: :long, dsl_single_hash: Mensa::Config::FormatDsl, name_attribute: :format
20
20
 
21
21
  option :visible, default: true
22
+ # Allows the user to group the table by this column (needs an SQL attribute)
23
+ option :groupable, default: false
24
+ # Aggregates the user can choose for this column in grouped tables,
25
+ # any of :count, :sum, :min and :max
26
+ option :aggregates, default: []
22
27
  option :render, dsl: Mensa::Config::RenderDsl
23
28
  option :filter, dsl: Mensa::Config::FilterDsl
29
+
30
+ # aggregates :sum, :min, :max
31
+ def aggregates(*functions)
32
+ config[:aggregates] = functions.flatten.map(&:to_sym)
33
+ end
24
34
  end
25
35
  end
@@ -67,6 +67,11 @@ module Mensa::Config
67
67
  # Order of columns in the table
68
68
  option :column_order
69
69
 
70
+ # Group rows by this column, e.g. `group_by :status`
71
+ option :group_by
72
+ # Aggregates shown per group, e.g. `aggregates market_cap: :sum`
73
+ option :aggregates
74
+
70
75
  # Actions
71
76
  option :action, dsl_hash: Mensa::Config::ActionDsl
72
77
 
@@ -7,5 +7,7 @@ module Mensa::Config
7
7
  option :filter, dsl_hash: Mensa::Config::FilterDsl
8
8
  option :column_order
9
9
  option :hidden_columns
10
+ option :group_by
11
+ option :aggregates
10
12
  end
11
13
  end
@@ -84,6 +84,9 @@ module Mensa
84
84
  else
85
85
  return apply_duplicate_filter(record_scope) if operator == :is_duplicate
86
86
 
87
+ # Nothing selected in a multi-select means no filter
88
+ return record_scope if value.is_a?(Array) && value.compact_blank.empty?
89
+
87
90
  query, hash = query_and_hash_for_operator
88
91
  record_scope = (column.filter.having? ? record_scope.having(query, hash) : record_scope.where(query, hash)) if query.present?
89
92
  record_scope
@@ -106,33 +109,33 @@ module Mensa
106
109
 
107
110
  # Used in the where clause
108
111
  def query_and_hash_for_operator
109
- hash = {column: column.attribute_for_condition, value: normalize(value)}
112
+ col = "(#{column.attribute_for_condition})"
113
+ hash = {value: normalize(value)}
114
+ hash[:value] = hash[:value].compact_blank if hash[:value].is_a?(Array)
110
115
 
111
116
  query = case operator
112
117
  when :is_empty
113
- (column.type == :string) ? ":column IS NULL OR :column = ''" : ":column IS NULL"
118
+ (column.type == :string) ? "#{col} IS NULL OR #{col} = ''" : "#{col} IS NULL"
114
119
  when :isnt_empty
115
- (column.type == :string) ? ":column IS NOT NULL AND :column != ''" : ":column IS NOT NULL"
120
+ (column.type == :string) ? "#{col} IS NOT NULL AND #{col} != ''" : "#{col} IS NOT NULL"
116
121
  when :is_current
117
- ":column = :value"
122
+ "#{col} = :value"
118
123
  when :matches
119
- ":column LIKE :value"
124
+ "#{col} LIKE :value"
120
125
  when :does_not_match
121
- ":column NOT LIKE :value"
126
+ "#{col} NOT LIKE :value"
122
127
  when :is
123
- hash[:value] = value if hash[:value].is_a?(Array)
124
- ":column = :value"
128
+ hash[:value].is_a?(Array) ? "#{col} IN (:value)" : "#{col} = :value"
125
129
  when :isnt
126
- hash[:value] = value if hash[:value].is_a?(Array)
127
- ":column != :value"
130
+ hash[:value].is_a?(Array) ? "#{col} NOT IN (:value)" : "#{col} != :value"
128
131
  when :gt
129
- ":column > :value"
132
+ "#{col} > :value"
130
133
  when :lt
131
- ":column < :value"
134
+ "#{col} < :value"
132
135
  when :gteq
133
- ":column >= :value"
136
+ "#{col} >= :value"
134
137
  when :lteq
135
- ":column <= :value"
138
+ "#{col} <= :value"
136
139
  else
137
140
  raise ArgumentError, "Unknown filter operator #{operator.inspect} for column :#{column.name}"
138
141
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Mensa
6
+ # A group of rows in a grouped table, see Mensa::Grouping
7
+ class Group
8
+ attr_reader :table, :value, :count, :aggregates
9
+
10
+ def initialize(table:, value:, count: nil, aggregates: {})
11
+ @table = table
12
+ @value = value
13
+ @count = count
14
+ @aggregates = aggregates
15
+ end
16
+
17
+ def column
18
+ table.group_column
19
+ end
20
+
21
+ # Stable identifier for the group, used to remember collapsed groups
22
+ def key
23
+ value.nil? ? "none" : Digest::SHA256.hexdigest(value.to_s)[0, 12]
24
+ end
25
+
26
+ def title
27
+ return I18n.t("mensa.groups.empty", column: column.human_name) if value.nil? || value == ""
28
+
29
+ Mensa::Cell.new(row: nil, column: column, value: value).formatted
30
+ end
31
+
32
+ # The aggregate for a column, formatted for display, nil when there is none
33
+ def aggregate(column)
34
+ return unless aggregates.key?(column.name)
35
+
36
+ value = aggregates[column.name]
37
+ return ActiveSupport::NumberHelper.number_to_delimited(value) if value.is_a?(Numeric)
38
+
39
+ Mensa::Cell.new(row: nil, column: column, value: value).formatted
40
+ end
41
+
42
+ def aggregate_label(column)
43
+ function = table.current_aggregates[column.name]
44
+ I18n.t("mensa.aggregates.#{function}") if function
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mensa
4
+ # Shows rows in groups: rows are sorted by the group column and every group
5
+ # gets a header with its row count and the chosen aggregates. Counts and
6
+ # aggregates are calculated with SQL GROUP BY over all filtered rows, not
7
+ # just the current page.
8
+ module Grouping
9
+ extend ActiveSupport::Concern
10
+
11
+ GROUP_VALUE_ALIAS = "mensa_group_value"
12
+
13
+ # The column the rows are grouped by, nil when not grouped
14
+ def group_column
15
+ return @group_column if defined?(@group_column)
16
+
17
+ name = current_group_by
18
+ col = column(name) if name.present?
19
+ @group_column = (col if col&.groupable? && scope.is_a?(ActiveRecord::Relation))
20
+ end
21
+
22
+ def grouped?
23
+ group_column.present?
24
+ end
25
+
26
+ def current_group_by
27
+ config[:group_by].presence
28
+ end
29
+
30
+ # The chosen aggregates as {column_name => function}, skipping columns and
31
+ # functions that don't allow it, e.g. from browser state saved earlier.
32
+ def current_aggregates
33
+ @current_aggregates ||= (config[:aggregates].presence || {}).to_h.filter_map { |name, function|
34
+ col = column(name)
35
+ next unless col && !col.internal?
36
+
37
+ function = col.aggregates.find { |f| f.to_s == function.to_s }
38
+ [col.name, function] if function
39
+ }.to_h
40
+ end
41
+
42
+ # All groups, keyed by group value
43
+ def groups
44
+ return {} unless grouped?
45
+
46
+ @groups ||= calculate_groups
47
+ end
48
+
49
+ # The group a row belongs to
50
+ def group_for(row)
51
+ value = row.group_value
52
+ groups[value] || Mensa::Group.new(table: self, value: value)
53
+ end
54
+
55
+ private
56
+
57
+ def group_order_clause
58
+ direction = (group_column.sort_direction == :desc) ? "desc" : "asc"
59
+ "#{group_column.attribute_for_condition} #{direction} NULLS LAST"
60
+ end
61
+
62
+ def group_select
63
+ Arel.sql("#{group_column.attribute_for_condition} AS #{GROUP_VALUE_ALIAS}")
64
+ end
65
+
66
+ # Runs the GROUP BY on a subquery of the filtered rows, so it also works
67
+ # for scopes that have their own GROUP BY/HAVING and for aggregate
68
+ # expressions as column attributes.
69
+ def calculate_groups
70
+ aggregates = current_aggregates.to_a
71
+ relation = filtered_scope.except(:select, :order, :limit, :offset)
72
+
73
+ inner_selects = [group_select]
74
+ aggregates.each_with_index do |(name, _function), index|
75
+ inner_selects << Arel.sql("#{column(name).attribute_for_condition} AS mensa_aggregate_#{index}")
76
+ end
77
+
78
+ outer_selects = ["mensa_groups.#{GROUP_VALUE_ALIAS}", "COUNT(*)"]
79
+ aggregates.each_with_index do |(_name, function), index|
80
+ outer_selects << "#{function.to_s.upcase}(mensa_groups.mensa_aggregate_#{index})"
81
+ end
82
+
83
+ rows = relation.model.unscoped
84
+ .from(relation.select(*inner_selects), :mensa_groups)
85
+ .group(Arel.sql("mensa_groups.#{GROUP_VALUE_ALIAS}"))
86
+ .pluck(*outer_selects.map { |sql| Arel.sql(sql) })
87
+
88
+ rows.to_h do |value, count, *aggregate_values|
89
+ values = aggregates.map(&:first).zip(aggregate_values).to_h
90
+ [value, Mensa::Group.new(table: self, value: value, count: count, aggregates: values)]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -23,6 +23,11 @@ module Mensa
23
23
  end
24
24
  end
25
25
 
26
+ # The value of the group column, when the table is grouped
27
+ def group_value
28
+ record[Mensa::Grouping::GROUP_VALUE_ALIAS] if record.respond_to?(:has_attribute?) && record.has_attribute?(Mensa::Grouping::GROUP_VALUE_ALIAS)
29
+ end
30
+
26
31
  def link_attributes
27
32
  return {} unless link
28
33