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.
- checksums.yaml +4 -4
- data/.shipkit.yml +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +38 -9
- data/app/assets/stylesheets/mensa/application.css +2 -0
- data/app/components/mensa/cell/component.html.erb +1 -1
- data/app/components/mensa/control_bar/component.html.erb +1 -0
- data/app/components/mensa/filter_pill_list/component_controller.js +55 -1
- data/app/components/mensa/group_by/component.css +49 -0
- data/app/components/mensa/group_by/component.html.erb +41 -0
- data/app/components/mensa/group_by/component.rb +27 -0
- data/app/components/mensa/group_by/component_controller.js +168 -0
- data/app/components/mensa/group_header/component.css +39 -0
- data/app/components/mensa/group_header/component.html.erb +34 -0
- data/app/components/mensa/group_header/component.rb +40 -0
- data/app/components/mensa/group_header/component_controller.js +63 -0
- data/app/components/mensa/table/component_controller.js +30 -1
- data/app/components/mensa/table_row/component.html.erb +1 -1
- data/app/components/mensa/table_row/component.rb +9 -1
- data/app/components/mensa/view/component.html.erb +17 -3
- data/app/controllers/mensa/tables/exports_controller.rb +1 -0
- data/app/controllers/mensa/tables/views_controller.rb +1 -1
- data/app/controllers/mensa/tables_controller.rb +3 -1
- data/app/javascript/mensa/controllers/index.js +6 -0
- data/app/tables/mensa/base.rb +14 -1
- data/app/tables/mensa/cell.rb +31 -4
- data/app/tables/mensa/column.rb +18 -0
- data/app/tables/mensa/config/column_dsl.rb +10 -0
- data/app/tables/mensa/config/table_dsl.rb +5 -0
- data/app/tables/mensa/config/view_dsl.rb +2 -0
- data/app/tables/mensa/filter.rb +17 -14
- data/app/tables/mensa/group.rb +47 -0
- data/app/tables/mensa/grouping.rb +94 -0
- data/app/tables/mensa/row.rb +5 -0
- data/app/tables/mensa/scope.rb +20 -12
- data/config/locales/en.yml +12 -0
- data/config/locales/nl.yml +12 -0
- data/lib/mensa/configuration.rb +6 -0
- data/lib/mensa/version.rb +1 -1
- 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
|
-
|
|
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, **
|
|
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
|
-
|
|
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 %>
|
|
@@ -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
|
|
data/app/tables/mensa/base.rb
CHANGED
|
@@ -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
|
data/app/tables/mensa/cell.rb
CHANGED
|
@@ -10,16 +10,45 @@ module Mensa
|
|
|
10
10
|
|
|
11
11
|
attr_reader :column, :row
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
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
|
data/app/tables/mensa/column.rb
CHANGED
|
@@ -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
|
|
data/app/tables/mensa/filter.rb
CHANGED
|
@@ -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
|
-
|
|
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) ? "
|
|
118
|
+
(column.type == :string) ? "#{col} IS NULL OR #{col} = ''" : "#{col} IS NULL"
|
|
114
119
|
when :isnt_empty
|
|
115
|
-
(column.type == :string) ? "
|
|
120
|
+
(column.type == :string) ? "#{col} IS NOT NULL AND #{col} != ''" : "#{col} IS NOT NULL"
|
|
116
121
|
when :is_current
|
|
117
|
-
"
|
|
122
|
+
"#{col} = :value"
|
|
118
123
|
when :matches
|
|
119
|
-
"
|
|
124
|
+
"#{col} LIKE :value"
|
|
120
125
|
when :does_not_match
|
|
121
|
-
"
|
|
126
|
+
"#{col} NOT LIKE :value"
|
|
122
127
|
when :is
|
|
123
|
-
hash[:value]
|
|
124
|
-
":column = :value"
|
|
128
|
+
hash[:value].is_a?(Array) ? "#{col} IN (:value)" : "#{col} = :value"
|
|
125
129
|
when :isnt
|
|
126
|
-
hash[:value]
|
|
127
|
-
":column != :value"
|
|
130
|
+
hash[:value].is_a?(Array) ? "#{col} NOT IN (:value)" : "#{col} != :value"
|
|
128
131
|
when :gt
|
|
129
|
-
"
|
|
132
|
+
"#{col} > :value"
|
|
130
133
|
when :lt
|
|
131
|
-
"
|
|
134
|
+
"#{col} < :value"
|
|
132
135
|
when :gteq
|
|
133
|
-
"
|
|
136
|
+
"#{col} >= :value"
|
|
134
137
|
when :lteq
|
|
135
|
-
"
|
|
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
|
data/app/tables/mensa/row.rb
CHANGED
|
@@ -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
|
|