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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3d23dc5f37173fea8736de1271cd1c55f192f9bf28df2b2df7bc6d5c8e0d922
|
|
4
|
+
data.tar.gz: 190e32dd6e3d9b8e8cd3fb34875535dc494873a923895a6306289df521a17ae3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b5913385d0de9990422c6a897631d8a3f119e025f48a994f976a30c94f7655f04874da6626e02d4aa6e88f4f45d952089b87784fb8228e2f24c14030342f950
|
|
7
|
+
data.tar.gz: ef17f02b50efaa802a7dbdbfe0fde4f8463da3c8c16fd3aa87b5344c2bbfcd21d52a36a072515a43d34caa21894bd3d4f7c75e6ac71ee613264d5692299c43c5
|
data/.shipkit.yml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
---
|
|
2
|
-
previous_version: 0.6.
|
|
2
|
+
previous_version: 0.6.13
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -22,15 +22,7 @@ Features:
|
|
|
22
22
|
- [X] Search works on all table columns
|
|
23
23
|
- [X] Exports can be scheduled to run recurring (daily/weekly/monthly/quarterly/bi-yearly/yearly)
|
|
24
24
|
You will have to bring your own mailer, see configuration for details.
|
|
25
|
-
|
|
26
|
-
TODO:
|
|
27
|
-
- [ ] ...
|
|
28
|
-
|
|
29
|
-
Nice to haves:
|
|
30
|
-
|
|
31
|
-
- [ ] group by
|
|
32
|
-
- [ ] sum/max/min
|
|
33
|
-
- [ ] tables backed by arrays (of ActiveModel)
|
|
25
|
+
- [X] Grouping rows by a column, with collapsible groups and count/sum/min/max per group
|
|
34
26
|
|
|
35
27
|
## Usage
|
|
36
28
|
|
|
@@ -61,6 +53,16 @@ class UserTable < ApplicationTable
|
|
|
61
53
|
attribute "roles_count" # We use a database column here
|
|
62
54
|
end
|
|
63
55
|
|
|
56
|
+
# Customize how a cell looks. Each cell is wrapped in a <td>, unless the html
|
|
57
|
+
# block returns its own td, so you can style the td itself.
|
|
58
|
+
column(:state) do
|
|
59
|
+
render do
|
|
60
|
+
html do |user|
|
|
61
|
+
content_tag(:td, user.human_state_name, class: "state state--#{user.state}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
64
66
|
# You can add one or more actions to a row
|
|
65
67
|
action :delete do
|
|
66
68
|
title "Delete row"
|
|
@@ -116,6 +118,33 @@ You can show your tables on the page using the following:
|
|
|
116
118
|
Custom views are views not defined by the developer (SystemViews) but by the end-user by adding/removing filters.
|
|
117
119
|
When you enable these, they are stored in the database and can be used across sessions, by the user who created them.
|
|
118
120
|
|
|
121
|
+
### Grouping
|
|
122
|
+
|
|
123
|
+
Rows can be shown in groups, each with a header that shows the group's value and
|
|
124
|
+
number of rows, and that collapses the group when clicked. Mark the columns users
|
|
125
|
+
may group by with `groupable`, and the aggregates they may choose per column with
|
|
126
|
+
`aggregates` (any of `:count`, `:sum`, `:min` and `:max`). Users pick both from the
|
|
127
|
+
group button in the control bar; their choice is remembered and saved with custom views.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
column(:status) do
|
|
131
|
+
groupable true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
column(:amount) do
|
|
135
|
+
aggregates :sum, :min, :max
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Optional defaults, also possible per view
|
|
139
|
+
group_by :status
|
|
140
|
+
aggregates amount: :sum
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Both need an SQL attribute: a database column, or an `attribute` expression. Rows are
|
|
144
|
+
sorted by the group column first (in the direction it is sorted, ascending otherwise),
|
|
145
|
+
then by the chosen order. Counts and aggregates are calculated with SQL `GROUP BY` over
|
|
146
|
+
all filtered rows, so they cover the whole group, also when it continues on the next page.
|
|
147
|
+
|
|
119
148
|
### Fast
|
|
120
149
|
|
|
121
150
|
Mensa selects only the data it needs, based on the columns. Sometimes it needs additional columns to do it's work, but you don't want them displayed. This can be done by adding `internal true` to the column definition, or shorter: use `internal` instead. If your table scope joins an association, Mensa also auto-adds the foreign key column as internal when it is needed.
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
@import "../../../components/mensa/empty_state/component.css";
|
|
6
6
|
@import "../../../components/mensa/filter_pill/component.css";
|
|
7
7
|
@import "../../../components/mensa/filter_pill_list/component.css";
|
|
8
|
+
@import "../../../components/mensa/group_by/component.css";
|
|
9
|
+
@import "../../../components/mensa/group_header/component.css";
|
|
8
10
|
@import "../../../components/mensa/header/component.css";
|
|
9
11
|
@import "../../../components/mensa/row_action/component.css";
|
|
10
12
|
@import "../../../components/mensa/search/component.css";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
<%= cell.render(:html) %>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<% if view_columns_ordering? %>
|
|
4
4
|
<%= render Mensa::ColumnCustomizer::Component.new(table: table) %>
|
|
5
5
|
<% end %>
|
|
6
|
+
<%= render Mensa::GroupBy::Component.new(table: table) %>
|
|
6
7
|
|
|
7
8
|
<div class="mensa-table__save-reset hidden" data-mensa-table-target="saveResetButtons">
|
|
8
9
|
<button class="mensa-table__control_bar__button" type="button" title="<%= t(".reset", default: "Reset") %>" data-action="mensa-table#cancelFiltersAndSearch">
|
|
@@ -180,6 +180,8 @@ export default class FilterPillListComponentController extends ApplicationContro
|
|
|
180
180
|
page: "",
|
|
181
181
|
};
|
|
182
182
|
this.persistState(state);
|
|
183
|
+
// The selected view brings its own grouping
|
|
184
|
+
this.clearPersistedGrouping();
|
|
183
185
|
this.setSearchField("");
|
|
184
186
|
this.updateSearchPlaceholder();
|
|
185
187
|
if (this.hasMensaTableOutlet) {
|
|
@@ -293,7 +295,9 @@ export default class FilterPillListComponentController extends ApplicationContro
|
|
|
293
295
|
query.length > 0 ||
|
|
294
296
|
Object.keys(order).length > 0 ||
|
|
295
297
|
columnOrder.length > 0 ||
|
|
296
|
-
hiddenColumns.length > 0
|
|
298
|
+
hiddenColumns.length > 0 ||
|
|
299
|
+
this.loadGroupBy() !== null ||
|
|
300
|
+
this.loadAggregates() !== null
|
|
297
301
|
) {
|
|
298
302
|
this._notifyUnsavedState();
|
|
299
303
|
}
|
|
@@ -352,6 +356,7 @@ export default class FilterPillListComponentController extends ApplicationContro
|
|
|
352
356
|
this.removeFilterParams(url);
|
|
353
357
|
this.removeOrderParams(url);
|
|
354
358
|
this.removeColumnParams(url);
|
|
359
|
+
this.removeGroupingParams(url);
|
|
355
360
|
url.searchParams.delete("query");
|
|
356
361
|
url.searchParams.delete("page");
|
|
357
362
|
url.searchParams.delete("table_view_id");
|
|
@@ -399,9 +404,30 @@ export default class FilterPillListComponentController extends ApplicationContro
|
|
|
399
404
|
url.searchParams.append("hidden_columns[]", col),
|
|
400
405
|
);
|
|
401
406
|
|
|
407
|
+
// Only send grouping the user chose, otherwise the view's default
|
|
408
|
+
// applies. Blank values mean "explicitly none".
|
|
409
|
+
const groupBy = this.loadGroupBy();
|
|
410
|
+
if (groupBy !== null) url.searchParams.set("group_by", groupBy);
|
|
411
|
+
const aggregates = this.loadAggregates();
|
|
412
|
+
if (aggregates !== null) {
|
|
413
|
+
const entries = Object.entries(aggregates);
|
|
414
|
+
if (entries.length === 0) url.searchParams.set("aggregates", "");
|
|
415
|
+
entries.forEach(([column, fn]) =>
|
|
416
|
+
url.searchParams.set(`aggregates[${column}]`, fn),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
402
420
|
return url;
|
|
403
421
|
}
|
|
404
422
|
|
|
423
|
+
removeGroupingParams(url) {
|
|
424
|
+
const keys = [];
|
|
425
|
+
url.searchParams.forEach((_v, key) => {
|
|
426
|
+
if (key === "group_by" || key.startsWith("aggregates")) keys.push(key);
|
|
427
|
+
});
|
|
428
|
+
keys.forEach((key) => url.searchParams.delete(key));
|
|
429
|
+
}
|
|
430
|
+
|
|
405
431
|
removeFilterParams(url) {
|
|
406
432
|
const keys = new Set();
|
|
407
433
|
url.searchParams.forEach((_v, key) => {
|
|
@@ -570,6 +596,28 @@ export default class FilterPillListComponentController extends ApplicationContro
|
|
|
570
596
|
this.writeStorage(this.orderStorageKey, null);
|
|
571
597
|
this.writeStorage(`mensa:column_order:${this.storageName}`, null);
|
|
572
598
|
this.writeStorage(`mensa:hidden_columns:${this.storageName}`, null);
|
|
599
|
+
this.clearPersistedGrouping();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
clearPersistedGrouping() {
|
|
603
|
+
this.writeStorage(this.groupByStorageKey, null);
|
|
604
|
+
this.writeStorage(this.aggregatesStorageKey, null);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// null when the user didn't choose, "" for "no grouping"
|
|
608
|
+
loadGroupBy() {
|
|
609
|
+
return this.readStorage(this.groupByStorageKey);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// null when the user didn't choose, {} for "no aggregates"
|
|
613
|
+
loadAggregates() {
|
|
614
|
+
const raw = this.readStorage(this.aggregatesStorageKey);
|
|
615
|
+
if (raw === null) return null;
|
|
616
|
+
try {
|
|
617
|
+
return JSON.parse(raw);
|
|
618
|
+
} catch (e) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
573
621
|
}
|
|
574
622
|
|
|
575
623
|
// Keep these for backward-compatibility with other controllers that look up
|
|
@@ -742,6 +790,12 @@ export default class FilterPillListComponentController extends ApplicationContro
|
|
|
742
790
|
get orderStorageKey() {
|
|
743
791
|
return `mensa:order:${this.storageName}`;
|
|
744
792
|
}
|
|
793
|
+
get groupByStorageKey() {
|
|
794
|
+
return `mensa:group_by:${this.storageName}`;
|
|
795
|
+
}
|
|
796
|
+
get aggregatesStorageKey() {
|
|
797
|
+
return `mensa:aggregates:${this.storageName}`;
|
|
798
|
+
}
|
|
745
799
|
|
|
746
800
|
get ourUrl() {
|
|
747
801
|
return this.mensaTableOutlet.ourUrl;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
.mensa-table {
|
|
2
|
+
&__control_bar__button[aria-pressed="true"] {
|
|
3
|
+
@apply text-primary-600 dark:text-primary-400;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
&__group_by {
|
|
7
|
+
&__popover {
|
|
8
|
+
@apply fixed z-50 w-72 max-h-96 overflow-y-auto rounded-xl bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 py-2;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
&__heading {
|
|
12
|
+
@apply px-4 pt-2 pb-1 text-sm font-semibold tracking-wider text-gray-900 dark:text-gray-500;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
&__option {
|
|
16
|
+
@apply w-full flex items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&__check {
|
|
20
|
+
@apply w-3 flex-none text-xs text-gray-700 dark:text-gray-200;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
&__option:not([aria-checked="true"]) &__check {
|
|
24
|
+
@apply invisible;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
&__aggregates {
|
|
28
|
+
@apply mt-1 border-t border-gray-100 dark:border-gray-700 pt-1;
|
|
29
|
+
|
|
30
|
+
&[aria-disabled="true"] {
|
|
31
|
+
@apply opacity-50 pointer-events-none;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&__aggregate {
|
|
36
|
+
@apply block px-4 py-1.5;
|
|
37
|
+
|
|
38
|
+
label {
|
|
39
|
+
@apply mb-1 block truncate text-sm font-medium text-gray-900 dark:text-gray-100;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* More specific than the table's general `.mensa-table select`, which
|
|
46
|
+
would otherwise win as it is imported later. */
|
|
47
|
+
.mensa-table .mensa-table__group_by__aggregate select {
|
|
48
|
+
@apply w-full py-1.5 pl-3 pr-8 text-sm;
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<div class="relative"
|
|
2
|
+
data-controller="mensa-group-by"
|
|
3
|
+
data-mensa-group-by-storage-key-value="<%= table.storage_key %>"
|
|
4
|
+
data-mensa-group-by-group-by-value="<%= table.group_column&.name %>"
|
|
5
|
+
data-mensa-group-by-aggregates-value="<%= table.current_aggregates.to_json %>"
|
|
6
|
+
data-mensa-group-by-mensa-table-outlet="#table-<%= table.table_id %>">
|
|
7
|
+
<button class="mensa-table__control_bar__button" type="button" title="<%= t("mensa.group_by.heading") %>" data-mensa-group-by-target="button" data-action="click->mensa-group-by#toggle">
|
|
8
|
+
<i class="<%= Mensa.config.icons[:group_by_toggle] %>"></i>
|
|
9
|
+
</button>
|
|
10
|
+
<div class="mensa-table__group_by__popover hidden" data-mensa-group-by-target="popover">
|
|
11
|
+
<p class="mensa-table__group_by__heading"><%= t("mensa.group_by.heading") %></p>
|
|
12
|
+
<ul>
|
|
13
|
+
<% [nil, *groupable_columns].each do |column| %>
|
|
14
|
+
<li>
|
|
15
|
+
<button class="mensa-table__group_by__option" type="button" data-column-name="<%= column&.name %>" data-mensa-group-by-target="groupOption" data-action="click->mensa-group-by#selectGroup">
|
|
16
|
+
<i class="mensa-table__group_by__check <%= Mensa.config.icons[:group_by_selected] %>"></i>
|
|
17
|
+
<span><%= column ? column.human_name : t("mensa.group_by.none") %></span>
|
|
18
|
+
</button>
|
|
19
|
+
</li>
|
|
20
|
+
<% end %>
|
|
21
|
+
</ul>
|
|
22
|
+
<% if aggregatable_columns.any? %>
|
|
23
|
+
<div class="mensa-table__group_by__aggregates" data-mensa-group-by-target="aggregates">
|
|
24
|
+
<p class="mensa-table__group_by__heading"><%= t("mensa.group_by.aggregates") %></p>
|
|
25
|
+
<ul>
|
|
26
|
+
<% aggregatable_columns.each do |column| %>
|
|
27
|
+
<li class="mensa-table__group_by__aggregate">
|
|
28
|
+
<label for="<%= "mensa-aggregate-#{table.table_id}-#{column.name}" %>"><%= column.human_name %></label>
|
|
29
|
+
<select id="<%= "mensa-aggregate-#{table.table_id}-#{column.name}" %>" data-column-name="<%= column.name %>" data-mensa-group-by-target="aggregateSelect" data-action="change->mensa-group-by#selectAggregate">
|
|
30
|
+
<option value=""><%= t("mensa.aggregates.none") %></option>
|
|
31
|
+
<% column.aggregates.each do |function| %>
|
|
32
|
+
<option value="<%= function %>"><%= t("mensa.aggregates.#{function}") %></option>
|
|
33
|
+
<% end %>
|
|
34
|
+
</select>
|
|
35
|
+
</li>
|
|
36
|
+
<% end %>
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mensa
|
|
4
|
+
module GroupBy
|
|
5
|
+
# Lets the user choose the column to group by and the aggregates shown in
|
|
6
|
+
# the group headers
|
|
7
|
+
class Component < ::Mensa::ApplicationComponent
|
|
8
|
+
attr_reader :table
|
|
9
|
+
|
|
10
|
+
def initialize(table:)
|
|
11
|
+
@table = table
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render?
|
|
15
|
+
groupable_columns.any?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def groupable_columns
|
|
19
|
+
@groupable_columns ||= table.columns.reject(&:internal?).select(&:groupable?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def aggregatable_columns
|
|
23
|
+
@aggregatable_columns ||= table.columns.reject(&:internal?).select(&:aggregatable?)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ApplicationController from "mensa/controllers/application_controller";
|
|
2
|
+
|
|
3
|
+
// The group-by popover in the control bar. The choice is stored in
|
|
4
|
+
// localStorage (like order and filters) and sent along by the filter pill
|
|
5
|
+
// list when it requests the table; a blank group_by or an empty aggregates
|
|
6
|
+
// object means "explicitly none", so it overrides the view's default.
|
|
7
|
+
export default class GroupByController extends ApplicationController {
|
|
8
|
+
static outlets = ["mensa-table"];
|
|
9
|
+
static targets = ["button", "popover", "groupOption", "aggregates", "aggregateSelect"];
|
|
10
|
+
static values = { storageKey: String, groupBy: String, aggregates: Object };
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
super.connect();
|
|
14
|
+
this._outsideClickHandler = null;
|
|
15
|
+
|
|
16
|
+
const groupBy = this._readStorage(this.groupByStorageKey);
|
|
17
|
+
if (groupBy !== null) this.groupByValue = groupBy;
|
|
18
|
+
const aggregates = this._readJson(this.aggregatesStorageKey);
|
|
19
|
+
if (aggregates !== null) this.aggregatesValue = aggregates;
|
|
20
|
+
this._render();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
disconnect() {
|
|
24
|
+
this._unbindOutsideClick();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toggle() {
|
|
28
|
+
if (this.popoverTarget.classList.contains("hidden")) {
|
|
29
|
+
this.popoverTarget.classList.remove("hidden");
|
|
30
|
+
this._positionPopover();
|
|
31
|
+
this._bindOutsideClick();
|
|
32
|
+
} else {
|
|
33
|
+
this._close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
selectGroup(event) {
|
|
38
|
+
this.groupByValue = event.currentTarget.dataset.columnName || "";
|
|
39
|
+
this._writeStorage(this.groupByStorageKey, this.groupByValue);
|
|
40
|
+
this._render();
|
|
41
|
+
this._reload();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
selectAggregate(event) {
|
|
45
|
+
const aggregates = { ...this.aggregatesValue };
|
|
46
|
+
const column = event.currentTarget.dataset.columnName;
|
|
47
|
+
if (event.currentTarget.value) {
|
|
48
|
+
aggregates[column] = event.currentTarget.value;
|
|
49
|
+
} else {
|
|
50
|
+
delete aggregates[column];
|
|
51
|
+
}
|
|
52
|
+
this.aggregatesValue = aggregates;
|
|
53
|
+
this._writeStorage(this.aggregatesStorageKey, JSON.stringify(aggregates));
|
|
54
|
+
this._render();
|
|
55
|
+
this._reload();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Called by the table controller with the state the server rendered,
|
|
59
|
+
// e.g. after selecting a view or resetting.
|
|
60
|
+
sync(groupBy, aggregates) {
|
|
61
|
+
this.groupByValue = groupBy || "";
|
|
62
|
+
this.aggregatesValue = aggregates || {};
|
|
63
|
+
this._render();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- private ---
|
|
67
|
+
|
|
68
|
+
_render() {
|
|
69
|
+
this.groupOptionTargets.forEach((option) => {
|
|
70
|
+
option.setAttribute(
|
|
71
|
+
"aria-checked",
|
|
72
|
+
((option.dataset.columnName || "") === this.groupByValue).toString(),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
this.aggregateSelectTargets.forEach((select) => {
|
|
76
|
+
select.value = this.aggregatesValue[select.dataset.columnName] || "";
|
|
77
|
+
});
|
|
78
|
+
if (this.hasAggregatesTarget) {
|
|
79
|
+
this.aggregatesTarget.setAttribute(
|
|
80
|
+
"aria-disabled",
|
|
81
|
+
(this.groupByValue === "").toString(),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (this.hasButtonTarget) {
|
|
85
|
+
this.buttonTarget.setAttribute(
|
|
86
|
+
"aria-pressed",
|
|
87
|
+
(this.groupByValue !== "").toString(),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_reload() {
|
|
93
|
+
const list = this.hasMensaTableOutlet
|
|
94
|
+
? this.mensaTableOutlet.mensaFilterPillListOutlet
|
|
95
|
+
: null;
|
|
96
|
+
if (list) list.refreshFilters();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_close() {
|
|
100
|
+
this.popoverTarget.classList.add("hidden");
|
|
101
|
+
this._unbindOutsideClick();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_positionPopover() {
|
|
105
|
+
const rect = this.buttonTarget.getBoundingClientRect();
|
|
106
|
+
const gap = 4;
|
|
107
|
+
const viewportPadding = 16;
|
|
108
|
+
const maxHeight = Math.min(
|
|
109
|
+
384,
|
|
110
|
+
window.innerHeight - rect.bottom - gap - viewportPadding,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
this.popoverTarget.style.top = `${rect.bottom + gap}px`;
|
|
114
|
+
this.popoverTarget.style.right = `${window.innerWidth - rect.right}px`;
|
|
115
|
+
this.popoverTarget.style.left = "auto";
|
|
116
|
+
this.popoverTarget.style.maxHeight = `${Math.max(maxHeight, 160)}px`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_bindOutsideClick() {
|
|
120
|
+
this._unbindOutsideClick();
|
|
121
|
+
this._outsideClickHandler = (event) => {
|
|
122
|
+
if (!this.element.contains(event.target)) this._close();
|
|
123
|
+
};
|
|
124
|
+
// Defer so the opening click doesn't immediately close the popover.
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
document.addEventListener("click", this._outsideClickHandler);
|
|
127
|
+
}, 0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_unbindOutsideClick() {
|
|
131
|
+
if (this._outsideClickHandler) {
|
|
132
|
+
document.removeEventListener("click", this._outsideClickHandler);
|
|
133
|
+
this._outsideClickHandler = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get groupByStorageKey() {
|
|
138
|
+
return `mensa:group_by:${this.storageKeyValue}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get aggregatesStorageKey() {
|
|
142
|
+
return `mensa:aggregates:${this.storageKeyValue}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_readJson(key) {
|
|
146
|
+
const raw = this._readStorage(key);
|
|
147
|
+
if (raw === null) return null;
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(raw);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_writeStorage(key, value) {
|
|
156
|
+
try {
|
|
157
|
+
window.localStorage.setItem(key, value);
|
|
158
|
+
} catch (e) {}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_readStorage(key) {
|
|
162
|
+
try {
|
|
163
|
+
return window.localStorage.getItem(key);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
.mensa-table {
|
|
2
|
+
table tbody tr.mensa-table__group-header {
|
|
3
|
+
@apply bg-gray-50 dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-700 select-none;
|
|
4
|
+
|
|
5
|
+
td {
|
|
6
|
+
@apply pt-3 pb-2 text-gray-900 dark:text-gray-100;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
&__group-header {
|
|
11
|
+
&__title {
|
|
12
|
+
@apply flex items-center gap-2;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
&__toggle {
|
|
16
|
+
@apply inline-flex w-4 justify-center text-xs text-gray-500 dark:text-gray-400 transition-transform;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&[aria-expanded="false"] &__toggle {
|
|
20
|
+
@apply -rotate-90;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
&__value {
|
|
24
|
+
@apply font-semibold;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
&__count {
|
|
28
|
+
@apply rounded-full bg-gray-200 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-300;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&__aggregate {
|
|
32
|
+
@apply text-sm font-medium text-gray-700 dark:text-gray-200;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&__aggregate-label {
|
|
36
|
+
@apply mr-1 text-xs font-normal uppercase text-gray-400 dark:text-gray-500;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<tr class="mensa-table__group-header"
|
|
2
|
+
data-mensa-groups-target="header"
|
|
3
|
+
data-group-key="<%= group.key %>"
|
|
4
|
+
data-action="click->mensa-groups#toggle"
|
|
5
|
+
aria-expanded="true">
|
|
6
|
+
<td colspan="<%= leading_cells + title_columns.size %>">
|
|
7
|
+
<div class="mensa-table__group-header__title">
|
|
8
|
+
<span class="mensa-table__group-header__toggle"><i class="<%= Mensa.config.icons[:group_toggle] %>"></i></span>
|
|
9
|
+
<span class="mensa-table__group-header__value"><%= group.title %></span>
|
|
10
|
+
<% if group.count %>
|
|
11
|
+
<span class="mensa-table__group-header__count"><%= group.count %></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% title_columns.select { |column| aggregate?(column) }.each do |column| %>
|
|
14
|
+
<span class="mensa-table__group-header__aggregate">
|
|
15
|
+
<span class="mensa-table__group-header__aggregate-label"><%= group.aggregate_label(column) %></span>
|
|
16
|
+
<%= group.aggregate(column) %>
|
|
17
|
+
</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
</td>
|
|
21
|
+
<% aggregate_columns.each do |column| %>
|
|
22
|
+
<td>
|
|
23
|
+
<% if aggregate?(column) %>
|
|
24
|
+
<span class="mensa-table__group-header__aggregate">
|
|
25
|
+
<span class="mensa-table__group-header__aggregate-label"><%= group.aggregate_label(column) %></span>
|
|
26
|
+
<%= group.aggregate(column) %>
|
|
27
|
+
</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
</td>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% if trailing_cells.positive? %>
|
|
32
|
+
<td></td>
|
|
33
|
+
<% end %>
|
|
34
|
+
</tr>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mensa
|
|
4
|
+
module GroupHeader
|
|
5
|
+
# The header row above each group of rows in a grouped table
|
|
6
|
+
class Component < ::Mensa::ApplicationComponent
|
|
7
|
+
attr_reader :table, :group
|
|
8
|
+
|
|
9
|
+
def initialize(table:, group:)
|
|
10
|
+
@table = table
|
|
11
|
+
@group = group
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The cells before the display columns (checkbox, front actions)
|
|
15
|
+
def leading_cells
|
|
16
|
+
(table.batch_actions? ? 1 : 0) + ((table.actions? && Mensa.config.row_actions_position == :front) ? 1 : 0)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def trailing_cells
|
|
20
|
+
(table.actions? && Mensa.config.row_actions_position == :back) ? 1 : 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The title spans the columns up to the first one with an aggregate, so
|
|
24
|
+
# the aggregates line up with their columns.
|
|
25
|
+
def title_columns
|
|
26
|
+
columns = table.display_columns
|
|
27
|
+
index = columns.index { |column| aggregate?(column) } || columns.size
|
|
28
|
+
columns.first([index, 1].max)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def aggregate_columns
|
|
32
|
+
table.display_columns.drop(title_columns.size)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def aggregate?(column)
|
|
36
|
+
group.aggregates.key?(column.name)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|