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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46f7dffd40faf8998fef427762261e9dd6cd5474a177e297e0829ac35b62fc7c
4
- data.tar.gz: 8b779ac0acb96d3bc9c1e75d8006c89f678ac98563e8882237d169e390b955c6
3
+ metadata.gz: a3d23dc5f37173fea8736de1271cd1c55f192f9bf28df2b2df7bc6d5c8e0d922
4
+ data.tar.gz: 190e32dd6e3d9b8e8cd3fb34875535dc494873a923895a6306289df521a17ae3
5
5
  SHA512:
6
- metadata.gz: 5e4a1c95251b0125871c5af7fb6c09285987c1e6de8f5a5139927d1793a195fe280f19c0bf90aa72a093b21d16d4906700976a680fb67abdfa6fa991ac72336f
7
- data.tar.gz: 8456ecef92194eef7807dc672f1c3d1fd05064ffa4b4569da78f70f297afa19e6d434322a4afedfceec66b92cfc311a4a8374dcf40d109be79039aa190fd5bfe
6
+ metadata.gz: 9b5913385d0de9990422c6a897631d8a3f119e025f48a994f976a30c94f7655f04874da6626e02d4aa6e88f4f45d952089b87784fb8228e2f24c14030342f950
7
+ data.tar.gz: ef17f02b50efaa802a7dbdbfe0fde4f8463da3c8c16fd3aa87b5344c2bbfcd21d52a36a072515a43d34caa21894bd3d4f7c75e6ac71ee613264d5692299c43c5
data/.shipkit.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ---
2
- previous_version: 0.6.12
2
+ previous_version: 0.6.13
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.6.11)
4
+ mensa (0.6.13)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
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
- <td><%= cell.render(:html) %></td>
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