mensa 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +6 -2
  3. data/.devcontainer/compose.yaml +1 -1
  4. data/.devcontainer/devcontainer.json +31 -29
  5. data/.devcontainer/postCreate.sh +8 -0
  6. data/.devcontainer/postStart.sh +9 -0
  7. data/.gitignore +3 -1
  8. data/.zed/tasks.json +12 -0
  9. data/CHANGELOG.md +6 -0
  10. data/Gemfile.lock +155 -153
  11. data/Procfile +1 -1
  12. data/README.md +95 -60
  13. data/app/assets/stylesheets/mensa/application.css +14 -11
  14. data/app/components/mensa/add_filter/component.css +110 -5
  15. data/app/components/mensa/add_filter/component.html.slim +10 -12
  16. data/app/components/mensa/add_filter/component.rb +8 -2
  17. data/app/components/mensa/add_filter/component_controller.js +697 -85
  18. data/app/components/mensa/cell/component.css +9 -0
  19. data/app/components/mensa/column_customizer/component.css +40 -0
  20. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  21. data/app/components/mensa/column_customizer/component.rb +13 -0
  22. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  23. data/app/components/mensa/control_bar/component.css +127 -4
  24. data/app/components/mensa/control_bar/component.html.slim +41 -14
  25. data/app/components/mensa/control_bar/component.rb +2 -6
  26. data/app/components/mensa/empty_state/component.css +20 -0
  27. data/app/components/mensa/empty_state/component.html.slim +7 -0
  28. data/app/components/mensa/empty_state/component.rb +18 -0
  29. data/app/components/mensa/filter_pill/component.css +23 -0
  30. data/app/components/mensa/filter_pill/component.html.slim +9 -0
  31. data/app/components/mensa/filter_pill/component.rb +24 -0
  32. data/app/components/mensa/filter_pill/component_controller.js +52 -0
  33. data/app/components/mensa/filter_pill_list/component.css +63 -0
  34. data/app/components/mensa/filter_pill_list/component.html.slim +11 -0
  35. data/app/components/mensa/{filter_list → filter_pill_list}/component.rb +1 -1
  36. data/app/components/mensa/filter_pill_list/component_controller.js +749 -0
  37. data/app/components/mensa/header/component.css +41 -43
  38. data/app/components/mensa/header/component.html.slim +7 -7
  39. data/app/components/mensa/header/component.rb +1 -1
  40. data/app/components/mensa/row_action/component.html.slim +2 -2
  41. data/app/components/mensa/row_action/component.rb +1 -1
  42. data/app/components/mensa/search/component.css +68 -9
  43. data/app/components/mensa/search/component.html.slim +19 -15
  44. data/app/components/mensa/search/component.rb +1 -1
  45. data/app/components/mensa/search/component_controller.js +39 -49
  46. data/app/components/mensa/selection/component_controller.js +147 -0
  47. data/app/components/mensa/table/component.css +28 -0
  48. data/app/components/mensa/table/component.html.slim +9 -6
  49. data/app/components/mensa/table/component.rb +1 -0
  50. data/app/components/mensa/table/component_controller.js +524 -76
  51. data/app/components/mensa/table_row/component.css +6 -0
  52. data/app/components/mensa/table_row/component.html.slim +8 -3
  53. data/app/components/mensa/table_row/component.rb +1 -1
  54. data/app/components/mensa/view/component.css +97 -29
  55. data/app/components/mensa/view/component.html.slim +23 -10
  56. data/app/components/mensa/view/component.rb +5 -0
  57. data/app/components/mensa/views/component.css +106 -13
  58. data/app/components/mensa/views/component.html.slim +51 -17
  59. data/app/components/mensa/views/component_controller.js +245 -20
  60. data/app/controllers/mensa/application_controller.rb +1 -1
  61. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  62. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  63. data/app/controllers/mensa/tables/filters_controller.rb +6 -2
  64. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  65. data/app/controllers/mensa/tables_controller.rb +5 -14
  66. data/app/helpers/mensa/application_helper.rb +4 -1
  67. data/app/javascript/mensa/application.js +2 -2
  68. data/app/javascript/mensa/controllers/application_controller.js +5 -21
  69. data/app/javascript/mensa/controllers/index.js +16 -7
  70. data/app/jobs/mensa/export_job.rb +77 -85
  71. data/app/models/mensa/export.rb +93 -0
  72. data/app/tables/mensa/action.rb +3 -1
  73. data/app/tables/mensa/base.rb +103 -17
  74. data/app/tables/mensa/batch_action.rb +27 -0
  75. data/app/tables/mensa/cell.rb +21 -6
  76. data/app/tables/mensa/column.rb +30 -25
  77. data/app/tables/mensa/config/action_dsl.rb +1 -1
  78. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  79. data/app/tables/mensa/config/column_dsl.rb +1 -0
  80. data/app/tables/mensa/config/dsl_logic.rb +8 -4
  81. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  82. data/app/tables/mensa/config/render_dsl.rb +1 -1
  83. data/app/tables/mensa/config/table_dsl.rb +14 -4
  84. data/app/tables/mensa/config/view_dsl.rb +2 -0
  85. data/app/tables/mensa/config_readers.rb +34 -3
  86. data/app/tables/mensa/filter.rb +94 -14
  87. data/app/tables/mensa/row.rb +1 -1
  88. data/app/tables/mensa/scope.rb +25 -13
  89. data/app/views/mensa/exports/_badge.html.slim +5 -0
  90. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  91. data/app/views/mensa/exports/_list.html.slim +29 -0
  92. data/app/views/mensa/tables/filters/show.turbo_stream.slim +34 -6
  93. data/app/views/mensa/tables/show.html.slim +2 -0
  94. data/app/views/mensa/tables/show.turbo_stream.slim +1 -1
  95. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  96. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  97. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  98. data/bin/setup +1 -1
  99. data/config/locales/en.yml +45 -1
  100. data/config/locales/nl.yml +46 -1
  101. data/config/routes.rb +7 -0
  102. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  103. data/docs/columns.png +0 -0
  104. data/docs/export.png +0 -0
  105. data/docs/filters.png +0 -0
  106. data/docs/table.png +0 -0
  107. data/lib/generators/mensa/tailwind_config_generator.rb +3 -3
  108. data/lib/generators/mensa/templates/config/initializers/mensa.rb +1 -1
  109. data/lib/mensa/configuration.rb +35 -15
  110. data/lib/mensa/engine.rb +15 -10
  111. data/lib/mensa/version.rb +1 -1
  112. data/lib/mensa.rb +2 -2
  113. data/lib/tasks/mensa_tasks.rake +1 -1
  114. data/mensa.gemspec +3 -2
  115. data/mise.toml +8 -0
  116. data/package-lock.json +0 -7
  117. metadata +60 -15
  118. data/app/components/mensa/filter/component_controller.js +0 -12
  119. data/app/components/mensa/filter_list/component.css +0 -14
  120. data/app/components/mensa/filter_list/component.html.slim +0 -14
  121. data/app/components/mensa/filter_list/component_controller.js +0 -14
  122. /data/{rubocop.yml → .rubocop.yml} +0 -0
@@ -1,54 +1,52 @@
1
1
  .mensa-table {
2
- thead {
3
- tr {
4
- @apply divide-x divide-gray-200 dark:divide-gray-600
5
- }
2
+ thead {
3
+ tr {
4
+ }
5
+
6
+ th {
7
+ @apply top-0 z-10 py-2 px-2 align-bottom text-left text-sm font-medium text-gray-500 border-b border-gray-200 dark:border-gray-600 whitespace-nowrap;
8
+
9
+ &:first-child {
10
+ @apply pl-4;
11
+ }
12
+
13
+ &:last-child {
14
+ @apply pr-4;
15
+ }
6
16
 
7
- th {
8
- @apply top-0 z-10 py-3.5 pl-6 pr-6 px-6 align-bottom text-left text-xs font-medium text-gray-500 uppercase border-b border-gray-200 dark:border-gray-600;
17
+ .container {
18
+ @apply h-4 flex items-center;
19
+ }
9
20
 
10
- .container {
11
- @apply h-4 flex items-center;
12
- }
21
+ .title {
22
+ @apply w-full;
23
+ }
13
24
 
14
- .title {
15
- @apply w-full pr-3;
16
- }
25
+ /* For sortable columns the link takes over the cell's padding so
26
+ the entire th area — including what was padding — is clickable. */
27
+ &:has(.order) {
28
+ @apply p-0;
17
29
 
18
- .actions {
19
- @apply pr-3 flex items-center;
30
+ .order {
31
+ @apply flex items-center gap-1 w-full py-2 px-2;
32
+ }
20
33
 
21
- .action {
22
- @apply cursor-pointer;
23
- /*.fa-arrow-down-arrow-up {*/
24
- @apply opacity-0;
25
- /*}*/
34
+ &:first-child .order {
35
+ @apply pl-4;
36
+ }
37
+
38
+ &:last-child .order {
39
+ @apply pr-4;
40
+ }
41
+ }
26
42
  }
27
- }
28
43
  }
29
44
 
30
- th:hover {
31
- .actions {
32
- .action {
33
- /*.fa-arrow-down-arrow-up {*/
34
- @apply opacity-100;
35
- /*}*/
45
+ &__column_edit {
46
+ thead {
47
+ th {
48
+ @apply cursor-grab;
49
+ }
36
50
  }
37
- }
38
- }
39
- }
40
- &__condensed {
41
- thead {
42
- th {
43
- @apply whitespace-nowrap py-2 pl-4 pr-3 px-0;
44
- }
45
- }
46
- }
47
- &__column_edit {
48
- thead {
49
- th {
50
- @apply cursor-grab;
51
- }
52
51
  }
53
- }
54
- }
52
+ }
@@ -1,8 +1,8 @@
1
1
  th
2
- .container
3
- .title = column.human_name
4
- .actions
5
- - if column.sortable?
6
- .action
7
- = link_to table.path(order: {column.name => column.next_sort_direction}, turbo_frame_id: table.table_id), "data-turbo-frame": "_self"
8
- i class=Mensa.config.icons["order_indicator#{column.sort_direction.to_s.present? ? "_#{column.sort_direction.to_s}" : ""}".to_sym]
2
+ - if column.sortable?
3
+ = link_to table.path(order: {column.name => column.next_sort_direction}, turbo_frame_id: table.table_id), "data-turbo-frame": "_self", class: "order cursor-pointer"
4
+ span = column.human_name
5
+ i class=Mensa.config.icons["order_indicator#{column.sort_direction.to_s.present? ? "_#{column.sort_direction.to_s}" : ""}".to_sym]
6
+ - else
7
+ .container
8
+ .title = column.human_name
@@ -16,4 +16,4 @@ module Mensa
16
16
  end
17
17
  end
18
18
  end
19
- end
19
+ end
@@ -1,6 +1,6 @@
1
1
  - if action.show.call(row.record)
2
- a href=(action.link ? table.original_view_context.instance_exec(row.record, &action.link) : '') title=action.title *action.link_attributes
2
+ a.action href=(action.link ? table.original_view_context.instance_exec(row.record, &action.link) : '') title=action.title *action.link_attributes
3
3
  - if action.icon
4
4
  i.fa class=action.icon
5
5
  - else
6
- = name
6
+ = name
@@ -16,4 +16,4 @@ module Mensa
16
16
  end
17
17
  end
18
18
  end
19
- end
19
+ end
@@ -1,13 +1,72 @@
1
1
  .mensa-table {
2
- &__search {
3
- @apply dark:bg-gray-800 dark:border-gray-800 border-b p-2;
2
+ &__search {
3
+ @apply dark:bg-gray-800 dark:border-gray-800 border-b p-2;
4
4
 
5
- &__button {
6
- @apply cursor-pointer w-6 h-full text-gray-400;
5
+ &__button {
6
+ @apply cursor-pointer w-6 h-full text-gray-400;
7
+ }
8
+
9
+ input&__input {
10
+ @apply pl-2 h-6 w-full border-0 bg-transparent pr-4 text-gray-600 dark:text-gray-400 placeholder:text-gray-400 focus:ring-0 text-sm;
11
+ }
7
12
  }
8
-
9
- input&__input {
10
- @apply pl-2 h-6 w-full border-0 bg-transparent pr-4 text-gray-600 dark:text-gray-400 placeholder:text-gray-400 focus:ring-0 text-sm;
13
+
14
+ &__save-view-dialog {
15
+ @apply w-[26rem] max-w-[90vw] rounded-2xl border-0 p-0 bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/5 dark:ring-white/10 text-gray-900 dark:text-gray-100;
16
+
17
+ &::backdrop {
18
+ @apply bg-gray-900/40;
19
+ backdrop-filter: blur(2px);
20
+ }
21
+
22
+ &__form {
23
+ @apply flex flex-col gap-5 p-6;
24
+ }
25
+
26
+ &__header {
27
+ @apply flex flex-col gap-1;
28
+ }
29
+
30
+ &__title {
31
+ @apply text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100;
32
+ }
33
+
34
+ &__subtitle {
35
+ @apply text-sm text-gray-500 dark:text-gray-400;
36
+ }
37
+
38
+ &__field {
39
+ @apply flex flex-col gap-1.5;
40
+ }
41
+
42
+ &__label {
43
+ @apply text-sm font-medium text-gray-700 dark:text-gray-300;
44
+ }
45
+
46
+ input&__input,
47
+ textarea&__textarea {
48
+ @apply w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 focus:outline-none;
49
+ border-radius: 0.5rem;
50
+ }
51
+
52
+ textarea&__textarea {
53
+ @apply resize-y min-h-[5rem];
54
+ }
55
+
56
+ &__actions {
57
+ @apply flex justify-end gap-2 pt-1;
58
+ }
59
+
60
+ &__button {
61
+ @apply rounded-lg px-3.5 py-2 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors;
62
+
63
+ &--secondary {
64
+ @apply bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:ring-gray-400;
65
+ }
66
+
67
+ &--primary {
68
+ @apply bg-primary-600 text-white hover:bg-primary-500 focus:ring-primary-500;
69
+ }
70
+ }
11
71
  }
12
- }
13
- }
72
+ }
@@ -1,17 +1,21 @@
1
- .mensa-table__search data-mensa-table-target="search" data-controller="mensa-search" data-mensa-search-target="search" class="#{table.supports_views? ? 'hidden' : ''}" data-mensa-search-mensa-table-outlet=".mensa-table#table-#{table.table_id}"
2
- .flex
3
- .flex-none.w-6.h-22.text-right.text-gray-500.dark:text-gray-400
4
- i.cursor-pointer class=Mensa.config.icons[:search]
5
- .flex-1
6
- input.mensa-table__search__input type="text" name="search_query" placeholder=t("search", default: "Search") data-mensa-search-target="searchInput" data-action="mensa-search#monitorSearch keydown.enter->mensa-search#search keydown.esc->mensa-search#resetSearch" value=params[:query]
7
- .flex.space-x-2.justify-right.pr-2
8
- button.mensa-table__search__button.hidden.pr-6 data-mensa-search-target="resetSearchButton" data-action="mensa-search#resetSearch"
9
- i.fas.fa-xmark
10
- .view-buttons.space-x-2.hidden data-mensa-table-target="viewButtons"
11
- button class="rounded bg-white px-2 py-1 text-xs font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" data-action="mensa-table#cancelFiltersAndSearch"
1
+ - if table.current_user
2
+ dialog.mensa-table__save-view-dialog data-mensa-table-target="saveViewDialog" data-action="click->mensa-table#saveViewDialogBackdrop"
3
+ form.mensa-table__save-view-dialog__form data-action="submit->mensa-table#confirmSaveView"
4
+ header.mensa-table__save-view-dialog__header
5
+ h2.mensa-table__save-view-dialog__title
6
+ = t('.save_view_title', default: 'Save view')
7
+ p.mensa-table__save-view-dialog__subtitle
8
+ = t('.save_view_subtitle', default: 'Save the current filters, ordering and search as a reusable view.')
9
+ label.mensa-table__save-view-dialog__field
10
+ span.mensa-table__save-view-dialog__label
11
+ = t('.view_name', default: 'Name')
12
+ input.mensa-table__save-view-dialog__input type="text" required=true placeholder=t('.view_name_placeholder', default: 'e.g. Active customers') data-mensa-table-target="saveViewName"
13
+ label.mensa-table__save-view-dialog__field
14
+ span.mensa-table__save-view-dialog__label
15
+ = t('.view_description', default: 'Description')
16
+ textarea.mensa-table__save-view-dialog__textarea rows="3" placeholder=t('.view_description_placeholder', default: 'Optional notes about this view') data-mensa-table-target="saveViewDescription"
17
+ .mensa-table__save-view-dialog__actions
18
+ button.mensa-table__save-view-dialog__button.mensa-table__save-view-dialog__button--secondary type="button" data-action="mensa-table#cancelSaveView"
12
19
  = t('.cancel')
13
- button class="rounded bg-white px-2 py-1 text-xs font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" data-action="mensa-table#saveFiltersAndSearch"
20
+ button.mensa-table__save-view-dialog__button.mensa-table__save-view-dialog__button--primary type="submit"
14
21
  = t('.save')
15
- - unless table.supports_views?
16
- = render Mensa::ControlBar::Component.new(table: table)
17
-
@@ -10,4 +10,4 @@ module Mensa
10
10
  end
11
11
  end
12
12
  end
13
- end
13
+ end
@@ -1,64 +1,54 @@
1
- import ApplicationController from "mensa/controllers/application_controller"
2
-
3
- import { get } from "@rails/request.js"
1
+ import ApplicationController from "mensa/controllers/application_controller";
4
2
 
5
3
  export default class SearchComponentController extends ApplicationController {
6
- static targets = ["resetSearchButton", "searchInput"]
7
- static outlets = ["mensa-table"]
8
- connect() {
9
- super.connect()
10
- this.monitorSearch()
11
- }
12
-
13
- monitorSearch(event) {
14
- event && event.preventDefault()
4
+ static targets = ["resetSearchButton", "searchInput"];
5
+ static outlets = ["mensa-filter-pill-list"];
15
6
 
16
- if (this.searchInputTarget.value.length >= 1) {
17
- this.resetSearchButtonTarget.classList.remove("hidden")
18
- this.searchInputTarget.focus()
19
- } else {
20
- this.resetSearchButtonTarget.classList.add("hidden")
7
+ connect() {
8
+ super.connect();
9
+ this.monitorSearch();
21
10
  }
22
- }
23
11
 
24
- resetSearch(event) {
25
- event.preventDefault()
12
+ monitorSearch(event) {
13
+ event && event.preventDefault();
26
14
 
27
- this.searchInputTarget.value = ""
28
- this.searchInputTarget.focus()
29
- this.resetSearchButtonTarget.classList.add("hidden")
30
-
31
- let turboFrame = this.element.closest("turbo-frame")
32
- let url = this.ourUrl
33
- url.searchParams.delete("query")
15
+ if (this.searchInputTarget.value.length >= 1) {
16
+ this.resetSearchButtonTarget.classList.remove("hidden");
17
+ this.searchInputTarget.focus();
18
+ } else {
19
+ this.resetSearchButtonTarget.classList.add("hidden");
20
+ }
21
+ }
34
22
 
35
- get(url, {
36
- responseKind: "turbo-stream",
37
- })
38
- }
23
+ resetSearch(event) {
24
+ event.preventDefault();
39
25
 
40
- search(event) {
41
- event.preventDefault()
26
+ this.searchInputTarget.value = "";
27
+ this.searchInputTarget.focus();
28
+ this.resetSearchButtonTarget.classList.add("hidden");
42
29
 
43
- if (this.query.length < 3) {
44
- return
30
+ // Re-request the table without a query (keeping any active filters) and
31
+ // forget the persisted query. The filter pill list owns persistence and the
32
+ // request so filters and search stay composed in a single call.
33
+ if (this.hasMensaFilterPillListOutlet) {
34
+ this.mensaFilterPillListOutlet.setQuery("");
35
+ }
45
36
  }
46
37
 
47
- // FIXME: This doesn't prevent searching twice on enter, the turbo-frame URL doesn't change
48
- let url = this.ourUrl
49
- if (url.searchParams.get("query") === this.query) {
50
- return
51
- }
38
+ search(event) {
39
+ event.preventDefault();
52
40
 
53
- url.searchParams.set("page", 1)
54
- url.searchParams.set("query", this.query)
41
+ if (this.query.length < 3) {
42
+ return;
43
+ }
55
44
 
56
- get(url, {
57
- responseKind: "turbo-stream",
58
- })
59
- }
45
+ // Persist the query and re-request the table, keeping any active filters.
46
+ if (this.hasMensaFilterPillListOutlet) {
47
+ this.mensaFilterPillListOutlet.setQuery(this.query);
48
+ }
49
+ }
60
50
 
61
- get query() {
62
- return this.searchInputTarget.value
63
- }
51
+ get query() {
52
+ return this.searchInputTarget.value;
53
+ }
64
54
  }
@@ -0,0 +1,147 @@
1
+ import ApplicationController from "mensa/controllers/application_controller";
2
+
3
+ // Manages row-level checkbox selection and the batch-action bar.
4
+ //
5
+ // The controller lives on the .overflow-y-auto.relative wrapper element.
6
+ //
7
+ // The batch bar is a div.mensa-batch-bar that is a sibling of the <table>,
8
+ // absolutely positioned at top:0/left:0 inside that wrapper. Its width is
9
+ // set to the <table>'s offsetWidth so it covers the full table — including
10
+ // any overflow that causes horizontal scrolling.
11
+ export default class SelectionController extends ApplicationController {
12
+ static targets = [
13
+ "headerCheckbox", // select-all checkbox in the header <th>
14
+ "rowCheckbox", // per-row checkboxes in <tbody>
15
+ "batchBar", // absolutely-positioned div overlaying the header row
16
+ "selectedCount", // span inside the batch bar showing "N selected"
17
+ "batchAllCheckbox", // checkbox inside the batch bar (clicking deselects all)
18
+ ];
19
+
20
+ static values = {
21
+ batchUrl: String, // POST endpoint for batch actions
22
+ };
23
+
24
+ connect() {
25
+ super.connect();
26
+ this.resizeHandler = () => this.syncBatchBarWidth();
27
+ window.addEventListener("resize", this.resizeHandler);
28
+ }
29
+
30
+ disconnect() {
31
+ window.removeEventListener("resize", this.resizeHandler);
32
+ }
33
+
34
+ // Sets the batch bar's width to match the <table> element so it covers
35
+ // the full scrollable width, not just the visible viewport portion.
36
+ syncBatchBarWidth() {
37
+ if (!this.hasBatchBarTarget) return;
38
+ const table = this.element.querySelector("table");
39
+ if (!table) return;
40
+ this.batchBarTarget.style.width = `${table.offsetWidth}px`;
41
+ }
42
+
43
+ // Called when the select-all checkbox in the header changes.
44
+ toggleAll(event) {
45
+ const checked = event.target.checked;
46
+ this.rowCheckboxTargets.forEach((cb) => {
47
+ cb.checked = checked;
48
+ });
49
+ this.updateSelectionState();
50
+ }
51
+
52
+ // Called when an individual row checkbox changes.
53
+ toggleRow() {
54
+ this.updateSelectionState();
55
+ }
56
+
57
+ // Called when the batch bar's checkbox is clicked — always deselects all.
58
+ deselectAll() {
59
+ this.rowCheckboxTargets.forEach((cb) => (cb.checked = false));
60
+ this.updateSelectionState();
61
+ }
62
+
63
+ // Stops click events from bubbling up to the row's satis-link handler,
64
+ // preventing accidental navigation when the user clicks a checkbox cell.
65
+ stopPropagation(event) {
66
+ event.stopPropagation();
67
+ }
68
+
69
+ // Builds and submits a hidden form for the chosen batch action.
70
+ // Reads selected record IDs from the checked row checkboxes.
71
+ executeBatch(event) {
72
+ event.preventDefault();
73
+
74
+ const actionName = event.params.batchAction;
75
+ const selectedIds = this.rowCheckboxTargets
76
+ .filter((cb) => cb.checked)
77
+ .map((cb) => cb.value);
78
+
79
+ if (selectedIds.length === 0) return;
80
+ if (!this.batchUrlValue) return;
81
+
82
+ const form = document.createElement("form");
83
+ form.method = "post";
84
+ form.action = this.batchUrlValue;
85
+
86
+ // Rails CSRF token
87
+ const csrfMeta = document.querySelector('meta[name="csrf-token"]');
88
+ if (csrfMeta) {
89
+ const csrfInput = document.createElement("input");
90
+ csrfInput.type = "hidden";
91
+ csrfInput.name = "authenticity_token";
92
+ csrfInput.value = csrfMeta.getAttribute("content");
93
+ form.appendChild(csrfInput);
94
+ }
95
+
96
+ // Batch action name
97
+ const actionInput = document.createElement("input");
98
+ actionInput.type = "hidden";
99
+ actionInput.name = "batch_action_name";
100
+ actionInput.value = actionName;
101
+ form.appendChild(actionInput);
102
+
103
+ // One hidden input per selected record ID
104
+ selectedIds.forEach((id) => {
105
+ const input = document.createElement("input");
106
+ input.type = "hidden";
107
+ input.name = "record_ids[]";
108
+ input.value = id;
109
+ form.appendChild(input);
110
+ });
111
+
112
+ document.body.appendChild(form);
113
+ form.submit();
114
+ }
115
+
116
+ // Syncs the header checkbox state and shows/hides the batch bar overlay.
117
+ updateSelectionState() {
118
+ const all = this.rowCheckboxTargets;
119
+ const checked = all.filter((cb) => cb.checked);
120
+ const hasSelection = checked.length > 0;
121
+ const allSelected = all.length > 0 && checked.length === all.length;
122
+ const someSelected = checked.length > 0 && checked.length < all.length;
123
+
124
+ // Keep the header select-all checkbox in sync
125
+ if (this.hasHeaderCheckboxTarget) {
126
+ this.headerCheckboxTarget.checked = allSelected;
127
+ this.headerCheckboxTarget.indeterminate = someSelected;
128
+ }
129
+
130
+ // Mirror the state on the batch bar's deselect-all checkbox
131
+ if (this.hasBatchAllCheckboxTarget) {
132
+ this.batchAllCheckboxTarget.checked = allSelected;
133
+ this.batchAllCheckboxTarget.indeterminate = someSelected;
134
+ }
135
+
136
+ // Show / hide the batch bar overlay
137
+ if (this.hasBatchBarTarget) {
138
+ if (hasSelection) this.syncBatchBarWidth();
139
+ this.batchBarTarget.classList.toggle("hidden", !hasSelection);
140
+ }
141
+
142
+ // Update "N selected" label
143
+ if (this.hasSelectedCountTarget) {
144
+ this.selectedCountTarget.textContent = `${checked.length} selected`;
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,28 @@
1
+ /* Hide view-origin filter pills when the eye toggle is in hidden state */
2
+ .mensa-table--view-filters-hidden [data-view-filter="true"] {
3
+ display: none;
4
+ }
5
+
6
+ /* Hide the + trigger when view filters are collapsed AND no user-added pills exist.
7
+ Using :not(:has(...)) keeps it visible whenever the user has their own filters.
8
+ The controller and its column-list ul stay in the DOM so the search-input focus
9
+ handler can still open the column list. */
10
+ .mensa-table--view-filters-hidden
11
+ .mensa-table__search-bar__pills-area:not(:has(.mensa-filter-pill:not([data-view-filter="true"])))
12
+ .mensa-table__add_filter__trigger {
13
+ display: none;
14
+ }
15
+
16
+ .mensa-table {
17
+ select {
18
+ @apply w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 pl-3 pr-10 text-gray-900 dark:text-gray-100 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 sm:text-sm;
19
+ }
20
+
21
+ &__toolbar {
22
+ @apply flex items-center gap-2 px-2 py-1 border-b border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800;
23
+ }
24
+
25
+ &__search-container {
26
+ @apply flex flex-1 min-w-0 items-stretch h-8 rounded-md bg-white dark:bg-gray-800;
27
+ }
28
+ }
@@ -1,7 +1,10 @@
1
- .mensa-table id="table-#{table.table_id}" data-mensa-table-view-condensed-value="#{table.view_condensed?}" data-mensa-table-supports-views-value="#{table.supports_views?}" data-controller="mensa-table" data-mensa-table-mensa-filter-outlet="[data-controller='mensa-filter']"
1
+ .mensa-table id="table-#{table.table_id}" data-mensa-table-supports-views-value="#{table.supports_views?}" data-mensa-table-table-url-value=helpers.mensa.table_url(table.name, {turbo_frame_id: table.table_id}.merge(params)) data-mensa-table-save-view-url-value=helpers.mensa.table_views_path(table.name) data-mensa-table-views-url-value=helpers.mensa.table_views_path(table.name) data-mensa-table-exports-url-value=helpers.mensa.table_exports_path(table.name) data-controller="mensa-table" data-mensa-table-mensa-filter-pill-outlet="[data-controller='mensa-filter-pill']" data-mensa-table-mensa-filter-pill-list-outlet="#mensa-filter-pill-list-#{table.table_id}" data-mensa-table-mensa-column-customizer-outlet="[data-controller='mensa-column-customizer']"
2
+ .mensa-table__toolbar
3
+ .mensa-table__search-container
4
+ - if table.supports_views? && table.show_header?
5
+ = render Mensa::Views::Component.new(table: table)
6
+ div.flex.flex-1.min-w-0 id="filters-#{table.table_id}"
7
+ = render Mensa::FilterPillList::Component.new(table: table)
8
+ = render Mensa::ControlBar::Component.new(table: table)
2
9
  = render Mensa::Search::Component.new(table: table)
3
- div id="filters-#{table.table_id}"
4
- = render Mensa::FilterList::Component.new(table: table)
5
- - if table.supports_views? && table.show_header?
6
- = render Mensa::Views::Component.new(table: table)
7
- turbo-frame id=table.table_id src=helpers.mensa.table_path(table.name, {turbo_frame_id: table.table_id}.merge(params)) target="_top" loading="lazy" data-mensa-table-target="turboFrame"
10
+ turbo-frame id=table.table_id target="_top" data-mensa-table-target="turboFrame" data-turbo-permanent=""
@@ -10,6 +10,7 @@ module Mensa
10
10
 
11
11
  def initialize(table_name, config = {}, **options)
12
12
  @table = Mensa.for_name(table_name, config)
13
+ @table.original_view_context = options[:original_view_context]
13
14
  @table.component = self
14
15
  @params = options[:params] || {}
15
16
  end