mensa 0.2.5 → 0.3.0

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 (98) 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/Gemfile.lock +155 -153
  10. data/Procfile +1 -1
  11. data/README.md +85 -60
  12. data/app/assets/stylesheets/mensa/application.css +14 -11
  13. data/app/components/mensa/add_filter/component.css +110 -5
  14. data/app/components/mensa/add_filter/component.html.slim +10 -12
  15. data/app/components/mensa/add_filter/component.rb +7 -1
  16. data/app/components/mensa/add_filter/component_controller.js +697 -83
  17. data/app/components/mensa/cell/component.css +9 -0
  18. data/app/components/mensa/column_customizer/component.css +40 -0
  19. data/app/components/mensa/column_customizer/component.html.slim +14 -0
  20. data/app/components/mensa/column_customizer/component.rb +13 -0
  21. data/app/components/mensa/column_customizer/component_controller.js +383 -0
  22. data/app/components/mensa/control_bar/component.css +127 -4
  23. data/app/components/mensa/control_bar/component.html.slim +41 -14
  24. data/app/components/mensa/control_bar/component.rb +0 -4
  25. data/app/components/mensa/empty_state/component.css +20 -0
  26. data/app/components/mensa/empty_state/component.html.slim +7 -0
  27. data/app/components/mensa/empty_state/component.rb +18 -0
  28. data/app/components/mensa/filter_pill/component.css +23 -0
  29. data/app/components/mensa/filter_pill/component.html.slim +9 -6
  30. data/app/components/mensa/filter_pill/component.rb +9 -0
  31. data/app/components/mensa/filter_pill/component_controller.js +50 -10
  32. data/app/components/mensa/filter_pill_list/component.css +58 -9
  33. data/app/components/mensa/filter_pill_list/component.html.slim +11 -8
  34. data/app/components/mensa/filter_pill_list/component_controller.js +747 -48
  35. data/app/components/mensa/header/component.css +41 -43
  36. data/app/components/mensa/header/component.html.slim +7 -7
  37. data/app/components/mensa/row_action/component.html.slim +2 -2
  38. data/app/components/mensa/search/component.css +68 -9
  39. data/app/components/mensa/search/component.html.slim +19 -15
  40. data/app/components/mensa/search/component_controller.js +39 -49
  41. data/app/components/mensa/selection/component_controller.js +147 -0
  42. data/app/components/mensa/table/component.css +28 -0
  43. data/app/components/mensa/table/component.html.slim +9 -6
  44. data/app/components/mensa/table/component.rb +1 -0
  45. data/app/components/mensa/table/component_controller.js +524 -88
  46. data/app/components/mensa/table_row/component.css +6 -0
  47. data/app/components/mensa/table_row/component.html.slim +8 -3
  48. data/app/components/mensa/view/component.css +97 -29
  49. data/app/components/mensa/view/component.html.slim +23 -10
  50. data/app/components/mensa/view/component.rb +5 -0
  51. data/app/components/mensa/views/component.css +106 -13
  52. data/app/components/mensa/views/component.html.slim +51 -17
  53. data/app/components/mensa/views/component_controller.js +245 -20
  54. data/app/controllers/mensa/tables/batch_actions_controller.rb +24 -0
  55. data/app/controllers/mensa/tables/exports_controller.rb +96 -0
  56. data/app/controllers/mensa/tables/filters_controller.rb +4 -1
  57. data/app/controllers/mensa/tables/views_controller.rb +108 -0
  58. data/app/controllers/mensa/tables_controller.rb +3 -6
  59. data/app/helpers/mensa/application_helper.rb +4 -0
  60. data/app/javascript/mensa/application.js +2 -2
  61. data/app/javascript/mensa/controllers/index.js +13 -4
  62. data/app/jobs/mensa/export_job.rb +77 -84
  63. data/app/models/mensa/export.rb +93 -0
  64. data/app/tables/mensa/base.rb +103 -12
  65. data/app/tables/mensa/batch_action.rb +27 -0
  66. data/app/tables/mensa/cell.rb +15 -0
  67. data/app/tables/mensa/column.rb +15 -2
  68. data/app/tables/mensa/config/batch_dsl.rb +13 -0
  69. data/app/tables/mensa/config/column_dsl.rb +1 -0
  70. data/app/tables/mensa/config/filter_dsl.rb +4 -1
  71. data/app/tables/mensa/config/render_dsl.rb +1 -1
  72. data/app/tables/mensa/config/table_dsl.rb +12 -5
  73. data/app/tables/mensa/config/view_dsl.rb +2 -0
  74. data/app/tables/mensa/config_readers.rb +20 -1
  75. data/app/tables/mensa/filter.rb +86 -3
  76. data/app/tables/mensa/scope.rb +24 -12
  77. data/app/views/mensa/exports/_badge.html.slim +5 -0
  78. data/app/views/mensa/exports/_dialog.html.slim +42 -0
  79. data/app/views/mensa/exports/_list.html.slim +29 -0
  80. data/app/views/mensa/tables/filters/show.turbo_stream.slim +35 -8
  81. data/app/views/mensa/tables/views/create.turbo_stream.slim +11 -0
  82. data/app/views/mensa/tables/views/destroy.turbo_stream.slim +11 -0
  83. data/app/views/mensa/tables/views/update.turbo_stream.slim +11 -0
  84. data/config/locales/en.yml +44 -0
  85. data/config/locales/nl.yml +45 -0
  86. data/config/routes.rb +7 -0
  87. data/db/migrate/20260604120000_create_mensa_exports.rb +25 -0
  88. data/docs/columns.png +0 -0
  89. data/docs/export.png +0 -0
  90. data/docs/filters.png +0 -0
  91. data/docs/table.png +0 -0
  92. data/lib/mensa/configuration.rb +33 -12
  93. data/lib/mensa/engine.rb +7 -2
  94. data/lib/mensa/version.rb +1 -1
  95. data/mensa.gemspec +2 -1
  96. data/mise.toml +8 -0
  97. data/package-lock.json +0 -7
  98. metadata +50 -8
@@ -1,46 +1,114 @@
1
- @import 'paging.css';
1
+ @import "paging.css";
2
2
 
3
- .mensa-table {
4
- @apply bg-white dark:bg-gray-500 dark:border-gray-700;
3
+ .mensa-batch-bar {
4
+ /* Sits on top of the thead row. Positioned relative to the .overflow-y-auto.relative
5
+ wrapper, which starts flush with the top of the table. top/left/right cover the
6
+ full width without any JS width-setting. py-2 + h-4 content mirrors the exact
7
+ height of the header row (same py-2 padding + h-4 container as thead th). */
8
+ position: absolute;
9
+ top: 0;
10
+ left: 0;
11
+ z-index: 10;
12
+ @apply pt-3 pb-2 bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600;
13
+
14
+ &__content {
15
+ /* h-4 matches the .container height used inside header th cells.
16
+ pl-3 aligns the checkbox with the checkbox column (w-8, pl-4). */
17
+ @apply h-4 flex items-center gap-2 pl-4;
18
+ }
5
19
 
6
- .badge {
7
- @apply bg-primary-100 text-primary-600 hidden ml-3 rounded-full text-xs font-medium md:inline-block py-0.5 px-2.5;
8
- }
20
+ &__count {
21
+ @apply text-xs font-medium text-gray-700 dark:text-gray-200 mr-1;
22
+ }
9
23
 
10
- table {
11
- @apply min-w-full divide-y divide-gray-50 dark:divide-gray-800 border-0;
24
+ &__button {
25
+ @apply inline-flex items-center gap-1 rounded px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-500 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none transition-colors cursor-pointer leading-none;
26
+ }
27
+ }
12
28
 
13
- thead {
14
- @apply bg-gray-100 top-0 dark:bg-gray-700 dark:font-medium dark:text-gray-400 dark:lowercase;
29
+ .mensa-table {
30
+ @apply bg-white dark:bg-gray-500 dark:border-gray-700;
15
31
 
16
- /* tr and th moved to header */
32
+ .mensa-table__checkbox-col {
33
+ @apply w-8;
17
34
  }
18
35
 
19
- tbody {
36
+ .mensa-table__select-all {
37
+ @apply h-4 w-4 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer;
38
+ appearance: none;
39
+
40
+ &:hover {
41
+ @apply border-gray-500 dark:border-gray-400;
42
+ }
43
+
44
+ &:checked {
45
+ &:hover {
46
+ @apply bg-gray-700 border-gray-700 dark:bg-gray-200 dark:border-gray-200;
47
+ }
48
+ }
49
+
50
+ &:indeterminate {
51
+ &:hover {
52
+ @apply bg-gray-700 border-gray-700 dark:bg-gray-200 dark:border-gray-200;
53
+ }
54
+ }
20
55
 
21
- @apply divide-y divide-gray-200 dark:divide-gray-600;
56
+ &:checked {
57
+ @apply bg-gray-900 border-gray-900 dark:bg-white dark:border-white;
58
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='white'%3E%3Cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3E%3C/svg%3E");
59
+ background-size: 100% 100%;
60
+ background-repeat: no-repeat;
61
+ background-position: center;
62
+ }
22
63
 
23
- tr {
24
- @apply cursor-pointer bg-white dark:bg-gray-800 divide-x divide-gray-400 divide-opacity-25 hover:bg-gray-200 dark:hover:bg-gray-700;
64
+ &:indeterminate {
65
+ @apply bg-gray-900 border-gray-900 dark:bg-white dark:border-white;
66
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");
67
+ background-size: 100% 100%;
68
+ background-repeat: no-repeat;
69
+ background-position: center;
70
+ }
25
71
 
26
- &:nth-child(even) {
27
- @apply bg-gray-50 dark:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700;
72
+ &:focus {
73
+ @apply outline-none ring-2 ring-offset-1 ring-gray-700 dark:ring-gray-300;
28
74
  }
29
- }
75
+ }
30
76
 
31
- td {
32
- @apply px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400;
33
- }
77
+ .badge {
78
+ @apply bg-primary-100 text-primary-600 hidden ml-3 rounded-full text-xs font-medium md:inline-block py-0.5 px-2.5;
34
79
  }
35
- }
36
80
 
37
- &__condensed {
38
81
  table {
39
- tbody {
40
- td {
41
- @apply pl-4 py-2;
82
+ @apply min-w-full divide-y divide-gray-50 dark:divide-gray-800 border-0;
83
+
84
+ thead {
85
+ @apply bg-gray-100 top-0 dark:bg-gray-700 dark:font-medium dark:text-gray-400 dark:lowercase;
86
+
87
+ /* tr and th moved to header */
88
+
89
+ .mensa-table__checkbox-col {
90
+ @apply pr-2;
91
+ }
92
+ }
93
+
94
+ tbody {
95
+ @apply divide-y divide-gray-200 dark:divide-gray-600;
96
+
97
+ tr {
98
+ @apply cursor-pointer bg-white dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700;
99
+ }
100
+
101
+ td {
102
+ @apply px-2 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400;
103
+
104
+ &:first-child {
105
+ @apply pl-4;
106
+ }
107
+
108
+ &:last-child {
109
+ @apply pr-4;
110
+ }
111
+ }
42
112
  }
43
- }
44
113
  }
45
- }
46
- }
114
+ }
@@ -1,17 +1,30 @@
1
- .overflow-y-auto class=(table.view_condensed? ? "mensa-table__condensed" : "") data-mensa-table-target="view"
1
+ .overflow-y-auto.relative data-mensa-table-target="view" data-controller="mensa-selection" data-mensa-selection-batch-url-value=(table.batch_actions? ? helpers.mensa.table_batch_actions_path(table.name) : "")
2
+ - if table.batch_actions?
3
+ .mensa-batch-bar.hidden data-mensa-selection-target="batchBar"
4
+ .mensa-batch-bar__content
5
+ input.mensa-table__select-all type="checkbox" data-action="click->mensa-selection#deselectAll" data-mensa-selection-target="batchAllCheckbox"
6
+ span.mensa-batch-bar__count data-mensa-selection-target="selectedCount"
7
+ - table.batch_actions.each do |batch_action|
8
+ button.mensa-batch-bar__button type="button" data-action="click->mensa-selection#executeBatch" data-mensa-selection-batch-action-param=batch_action.name.to_s
9
+ = batch_action.title
2
10
  table.w-full
3
11
  - if table.show_header?
4
12
  thead
5
13
  tr
6
- = render(Mensa::Header::Component.with_collection(table.columns.select(&:visible?).reject(&:internal?), table: table))
7
- - if table.actions?
8
- th
9
-
14
+ - if table. batch_actions?
15
+ th.mensa-table__checkbox-col
16
+ .container
17
+ input.mensa-table__select-all type="checkbox" data-action="change->mensa-selection#toggleAll" data-mensa-selection-target="headerCheckbox"
18
+ - if table.actions? && Mensa.config.row_actions_position == :front
19
+ th Actions
20
+ = render(Mensa::Header::Component.with_collection(table.display_columns, table: table))
21
+ - if table.actions? && Mensa.config.row_actions_position == :back
22
+ th Actions
10
23
  tbody
11
24
  = render(Mensa::TableRow::Component.with_collection(table.rows, table: table))
12
-
13
- - if table.pagy_details&.last > 1 || table.pagy_details.count == 0
25
+ - if table.pagy_details&.count == 0
26
+ = render Mensa::EmptyState::Component.new(table: table)
27
+ - elsif table.pagy_details&.last > 1
14
28
  .paging
15
- == table.pagy_details.info_tag
16
- - if table.pagy_details&.last > 1
17
- == table.pagy_details.series_nav(anchor_string: 'data-turbo-frame="_self"')
29
+ span = t("mensa.paging.info", model: model_name_plural, from: table.pagy_details.from, to: table.pagy_details.to, count: table.pagy_details.count)
30
+ == table.pagy_details.series_nav(anchor_string: 'data-turbo-frame="_self"')
@@ -11,6 +11,11 @@ module Mensa
11
11
  def initialize(table)
12
12
  @table = table
13
13
  end
14
+
15
+ # "orders", "users", etc. — used in the paging info line.
16
+ def model_name_plural
17
+ table.model.model_name.human.pluralize.downcase
18
+ end
14
19
  end
15
20
  end
16
21
  end
@@ -1,22 +1,115 @@
1
1
  .mensa-table {
2
- &__views {
3
- @apply p-2 pl-4 shadow-lg border-b dark:bg-gray-700 dark:border-gray-600;
2
+ &__views {
3
+ @apply relative flex-none;
4
4
 
5
- a.view {
6
- @apply bg-gray-100 text-gray-600 dark:text-gray-400 hover:text-gray-800 hover:bg-gray-200 rounded-md px-3 py-1.5 text-sm;
7
- }
5
+ &__trigger {
6
+ @apply h-full flex items-center gap-1.5 px-3 text-sm font-semibold text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 whitespace-nowrap cursor-pointer transition-colors;
7
+ }
8
+
9
+ &__trigger-icon {
10
+ @apply text-gray-500 dark:text-gray-400 text-xs;
11
+ }
12
+
13
+ &__dropdown {
14
+ @apply absolute left-0 top-full z-20 mt-1 min-w-[12rem] rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 py-1;
15
+ }
16
+
17
+ &__option {
18
+ @apply flex items-center justify-between px-1 hover:bg-gray-50 dark:hover:bg-gray-700;
19
+
20
+ &-btn {
21
+ @apply flex items-center gap-2 flex-1 px-2 py-1.5 text-sm text-left text-gray-700 dark:text-gray-200 cursor-pointer;
22
+ }
23
+
24
+ &-check {
25
+ @apply text-primary-600 text-xs w-4;
26
+ }
27
+
28
+ &-menu {
29
+ @apply p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded cursor-pointer opacity-0 transition-opacity;
30
+ }
31
+
32
+ &:hover &-menu {
33
+ @apply opacity-100;
34
+ }
35
+
36
+ &-system {
37
+ @apply p-1.5 text-gray-400 opacity-0;
38
+ }
39
+
40
+ &:hover &-system {
41
+ @apply p-1.5 text-gray-300 opacity-100;
42
+ }
43
+ }
44
+
45
+ &__submenu {
46
+ @apply absolute z-30 min-w-[11rem] rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 py-1;
47
+
48
+ &-item {
49
+ @apply flex items-center gap-2.5 w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer;
50
+
51
+ i {
52
+ @apply w-4 text-gray-400;
53
+ }
8
54
 
9
- a.view.selected {
10
- @apply bg-gray-300 text-gray-800 dark:bg-gray-500 hover:bg-gray-400;
55
+ &--danger {
56
+ @apply text-red-600 dark:text-red-400;
57
+
58
+ i {
59
+ @apply text-red-500;
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ &__rename-dialog {
66
+ @apply w-80 max-w-[90vw] rounded-xl border-0 p-5 bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/5 dark:ring-white/10;
67
+
68
+ &::backdrop {
69
+ @apply bg-gray-900/40;
70
+ backdrop-filter: blur(2px);
71
+ }
72
+ }
73
+
74
+ &__rename-form {
75
+ @apply flex flex-col gap-4;
76
+ }
77
+
78
+ &__rename-title {
79
+ @apply text-base font-semibold text-gray-900 dark:text-gray-100;
80
+ }
81
+
82
+ &__rename-label {
83
+ @apply flex flex-col gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-300;
84
+ }
85
+
86
+ input&__rename-input {
87
+ @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;
88
+ border-radius: 0.5rem;
89
+ }
90
+
91
+ &__rename-actions {
92
+ @apply flex justify-end gap-2;
93
+ }
94
+
95
+ &__rename-btn {
96
+ @apply rounded-lg px-3 py-1.5 text-sm font-semibold shadow-sm focus:outline-none transition-colors;
97
+
98
+ &--secondary {
99
+ @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;
100
+ }
101
+
102
+ &--primary {
103
+ @apply bg-primary-600 text-white hover:bg-primary-500;
104
+ }
105
+ }
11
106
  }
12
- }
13
107
  }
14
108
 
15
- .tippy-box[data-theme~='mensa'] {
16
- @apply bg-gray-200 text-gray-800 dark:text-gray-400;
109
+ .tippy-box[data-theme~="mensa"] {
110
+ @apply bg-gray-200 text-gray-800 dark:text-gray-400;
17
111
  }
18
112
 
19
- .tippy-box[data-theme~='mensa'] .tippy-arrow {
20
- @apply text-gray-200;
113
+ .tippy-box[data-theme~="mensa"] .tippy-arrow {
114
+ @apply text-gray-200;
21
115
  }
22
-
@@ -1,18 +1,52 @@
1
- .mensa-table__views data-mensa-table-target="views" data-controller="mensa-views"
2
- .sm:hidden
3
- label.sr-only[for="tabs"]
4
- | Select a tab
5
- select#tabs.block.w-full.rounded-md.border-gray-300.focus:border-indigo-500.focus:ring-indigo-500[name="tabs"]
6
- - table.all_views.each.with_index do |view, index|
7
- option = view.name
8
- .hidden.sm:block
9
- nav.flex.justify-between[aria-label="Tabs"]
10
- .flex.space-x-2.overflow-x-auto.whitespace-nowrap.scroll-p-0
11
- - table.all_views.each.with_index do |view, index|
12
- = link_to(table.path(table_view_id: view.id, turbo_frame_id: table.table_id), "data-turbo-frame": table.table_id, class: "view #{(view.id == table.table_view&.id || view.id == :default && table.table_view.blank?) ? 'selected' : ''}", data: {"mensa-views-target": "view", action: "mensa-views#select", "tippy-content": view.description}) do
13
- = view.name
1
+ - current_view = table.all_views.find { |v| v.id == table.table_view&.id } || table.all_views.first
2
+ - current_view_name = current_view&.name || t(".all", default: "All")
3
+ - current_view_id = current_view&.id || ""
14
4
 
15
- - if table.supports_custom_views?
16
- a.text-gray-600.dark:text-gray-400.hover:text-gray-800.hover:bg-gray-200.rounded-md.px-3.py-1.5.text-sm[href="#" title=t("new_view", default: "New view")]
17
- .fa-solid.fa-plus
18
- = render Mensa::ControlBar::Component.new(table: table)
5
+ .mensa-table__views id="mensa-views-#{table.table_id}" data-mensa-table-target="views" data-controller="mensa-views" data-mensa-views-mensa-filter-pill-list-outlet="#mensa-filter-pill-list-#{table.table_id}" data-mensa-views-table-id-value=table.table_id data-mensa-views-views-url-value=helpers.mensa.table_views_path(table.name)
6
+ button.mensa-table__views__trigger type="button" data-action="mensa-views#toggleDropdown" data-mensa-views-target="trigger" aria-haspopup="listbox"
7
+ span.mensa-table__views__trigger-label data-mensa-views-target="triggerLabel"
8
+ = current_view_name
9
+ i.fa-solid.fa-sort.mensa-table__views__trigger-icon
10
+
11
+ .mensa-table__views__dropdown.hidden data-mensa-views-target="dropdown"
12
+ ul role="listbox"
13
+ - table.all_views.each do |view|
14
+ - is_selected = view.id == table.table_view&.id || (view.id == :default && table.table_view.blank?)
15
+ - is_user_view = view.id.is_a?(String) && view.id.match?(/\A[0-9a-f-]{32,}\z/i)
16
+ li.mensa-table__views__option[role="option" data-view-id=view.id data-view-name=view.name data-mensa-views-target="view"]
17
+ button.mensa-table__views__option-btn[type="button" data-action="mensa-views#select" data-view-id=view.id]
18
+ i.mensa-table__views__option-check.fa-solid.fa-check class=("invisible" unless is_selected)
19
+ span = view.name
20
+ - if is_user_view
21
+ button.mensa-table__views__option-menu[type="button" data-action="mensa-views#toggleSubmenu" data-view-id=view.id title="View options"]
22
+ i.fa-solid.fa-ellipsis
23
+ - else
24
+ .mensa-table__views__option-system
25
+ i.fa-solid.fa-ban
26
+
27
+ / Per-view submenu (shown via JS positioning)
28
+ .mensa-table__views__submenu.hidden data-mensa-views-target="submenu"
29
+ button.mensa-table__views__submenu-item[type="button" data-action="mensa-views#renameView"]
30
+ i.fa-solid.fa-pencil
31
+ = t(".rename_view", default: "Rename view")
32
+ button.mensa-table__views__submenu-item[type="button" data-action="mensa-views#duplicateView"]
33
+ i.fa-solid.fa-copy
34
+ = t(".duplicate_view", default: "Duplicate view")
35
+ button.mensa-table__views__submenu-item.mensa-table__views__submenu-item--danger[type="button" data-action="mensa-views#deleteView"]
36
+ i.fa-solid.fa-trash
37
+ = t(".delete_view", default: "Delete view")
38
+
39
+ / Rename dialog
40
+ dialog.mensa-table__views__rename-dialog data-mensa-views-target="renameDialog" data-action="click->mensa-views#renameDialogBackdrop"
41
+ form.mensa-table__views__rename-form data-action="submit->mensa-views#confirmRename"
42
+ input type="hidden" data-mensa-views-target="renameViewId"
43
+ h3.mensa-table__views__rename-title
44
+ = t(".rename_view", default: "Rename view")
45
+ label.mensa-table__views__rename-label
46
+ = t(".view_name", default: "Name")
47
+ input.mensa-table__views__rename-input[type="text" required=true placeholder=t(".view_name_placeholder", default: "View name") data-mensa-views-target="renameInput"]
48
+ .mensa-table__views__rename-actions
49
+ button.mensa-table__views__rename-btn.mensa-table__views__rename-btn--secondary[type="button" data-action="mensa-views#cancelRename"]
50
+ = t(".cancel", default: "Cancel")
51
+ button.mensa-table__views__rename-btn.mensa-table__views__rename-btn--primary[type="submit"]
52
+ = t(".rename", default: "Rename")
@@ -1,22 +1,247 @@
1
- import ApplicationController from 'mensa/controllers/application_controller'
2
- import tippy from 'tippy.js'
1
+ import ApplicationController from "mensa/controllers/application_controller";
2
+ import { patch, post, destroy } from "@rails/request.js";
3
3
 
4
4
  export default class ViewsComponentController extends ApplicationController {
5
- static targets = [
6
- 'view',
7
- ]
8
-
9
- connect() {
10
- tippy('[data-controller="mensa-views"] [data-tippy-content]', {
11
- placement: "top",
12
- theme: 'mensa',
13
- offset: [0, 8]
14
- });
15
- }
16
-
17
- select(event) {
18
- this.viewTargets.forEach((element) => {
19
- (element === event.target) ? element.classList.add('selected') : element.classList.remove('selected')
20
- })
21
- }
22
- }
5
+ static targets = ["trigger", "triggerLabel", "dropdown", "view", "submenu", "renameDialog", "renameInput", "renameViewId"];
6
+ static outlets = ["mensa-filter-pill-list"];
7
+ static values = {
8
+ tableId: String,
9
+ viewsUrl: String,
10
+ };
11
+
12
+ connect() {
13
+ this._activeSubmenuViewId = null;
14
+ this._outsideClickHandler = null;
15
+ }
16
+
17
+ disconnect() {
18
+ this._unbindOutsideClick();
19
+ }
20
+
21
+ toggleDropdown(event) {
22
+ event.stopPropagation();
23
+ const isOpen = !this.dropdownTarget.classList.contains("hidden");
24
+ if (isOpen) {
25
+ this._closeDropdown();
26
+ } else {
27
+ this._openDropdown();
28
+ }
29
+ }
30
+
31
+ select(event) {
32
+ event.preventDefault();
33
+ event.stopPropagation();
34
+
35
+ const selected = event.currentTarget;
36
+ const viewId = selected.dataset.viewId || "";
37
+
38
+ this.viewTargets.forEach((el) => {
39
+ const btn = el.querySelector("[data-action='mensa-views#select']");
40
+ const check = el.querySelector(".mensa-table__views__option-check");
41
+ const isThis = el.dataset.viewId === viewId;
42
+ check?.classList.toggle("invisible", !isThis);
43
+ });
44
+
45
+ const viewName = event.currentTarget.querySelector("span")?.textContent?.trim() || "";
46
+ if (this.hasTriggerLabelTarget) {
47
+ this.triggerLabelTarget.textContent = viewName;
48
+ }
49
+
50
+ this._closeDropdown();
51
+
52
+ if (this.hasMensaFilterPillListOutlet) {
53
+ this.mensaFilterPillListOutlet.viewSelected(viewId);
54
+ }
55
+ }
56
+
57
+ toggleSubmenu(event) {
58
+ event.preventDefault();
59
+ event.stopPropagation();
60
+
61
+ const viewId = event.currentTarget.dataset.viewId;
62
+ const optionEl = event.currentTarget.closest("[data-mensa-views-target='view']");
63
+
64
+ if (this._activeSubmenuViewId === viewId && !this.submenuTarget.classList.contains("hidden")) {
65
+ this._closeSubmenu();
66
+ return;
67
+ }
68
+
69
+ this._activeSubmenuViewId = viewId;
70
+ this.submenuTarget.dataset.viewId = viewId;
71
+
72
+ // Position the submenu next to the clicked button
73
+ const rect = event.currentTarget.getBoundingClientRect();
74
+ const dropdownRect = this.dropdownTarget.getBoundingClientRect();
75
+ this.submenuTarget.style.top = `${rect.top - dropdownRect.top}px`;
76
+ this.submenuTarget.style.left = `${dropdownRect.width}px`;
77
+
78
+ this.submenuTarget.classList.remove("hidden");
79
+ }
80
+
81
+ renameView(event) {
82
+ event.preventDefault();
83
+ const viewId = this.submenuTarget.dataset.viewId;
84
+ const viewEl = this.viewTargets.find((el) => el.dataset.viewId === viewId);
85
+ const viewName = viewEl?.dataset.viewName || "";
86
+
87
+ this._closeSubmenu();
88
+ this._closeDropdown();
89
+
90
+ if (this.hasRenameDialogTarget) {
91
+ this.renameViewIdTarget.value = viewId;
92
+ this.renameInputTarget.value = viewName;
93
+ if (typeof this.renameDialogTarget.showModal === "function") {
94
+ this.renameDialogTarget.showModal();
95
+ } else {
96
+ this.renameDialogTarget.setAttribute("open", "");
97
+ }
98
+ this.renameInputTarget.select();
99
+ }
100
+ }
101
+
102
+ cancelRename(event) {
103
+ if (event) event.preventDefault();
104
+ this._closeRenameDialog();
105
+ }
106
+
107
+ renameDialogBackdrop(event) {
108
+ if (event.target === this.renameDialogTarget) {
109
+ this._closeRenameDialog();
110
+ }
111
+ }
112
+
113
+ async confirmRename(event) {
114
+ event.preventDefault();
115
+ const viewId = this.renameViewIdTarget.value;
116
+ const newName = this.renameInputTarget.value.trim();
117
+ if (!newName) {
118
+ this.renameInputTarget.reportValidity();
119
+ return;
120
+ }
121
+
122
+ const turboFrameId = this._turboFrameId();
123
+ const response = await patch(`${this.viewsUrlValue}/${viewId}`, {
124
+ body: JSON.stringify({ name: newName, turbo_frame_id: turboFrameId }),
125
+ contentType: "application/json",
126
+ responseKind: "turbo-stream",
127
+ });
128
+
129
+ if (response.ok) {
130
+ this._closeRenameDialog();
131
+ }
132
+ }
133
+
134
+ async duplicateView(event) {
135
+ event.preventDefault();
136
+ const viewId = this.submenuTarget.dataset.viewId;
137
+ const viewEl = this.viewTargets.find((el) => el.dataset.viewId === viewId);
138
+ const viewName = viewEl?.dataset.viewName || "";
139
+
140
+ this._closeSubmenu();
141
+ this._closeDropdown();
142
+
143
+ // Read the current state from the filter pill list outlet
144
+ let state = { filters: {}, query: "", order: {} };
145
+ if (this.hasMensaFilterPillListOutlet) {
146
+ const outlet = this.mensaFilterPillListOutlet;
147
+ state = {
148
+ filters: outlet.collectFilters(),
149
+ query: outlet.loadQuery(),
150
+ order: outlet.loadOrder(),
151
+ column_order: outlet.loadColumnOrder(),
152
+ hidden_columns: outlet.loadHiddenColumns(),
153
+ };
154
+ }
155
+
156
+ const turboFrameId = this._turboFrameId();
157
+ await post(this.viewsUrlValue, {
158
+ body: JSON.stringify({
159
+ name: `${viewName} (copy)`,
160
+ ...state,
161
+ turbo_frame_id: turboFrameId,
162
+ }),
163
+ contentType: "application/json",
164
+ responseKind: "turbo-stream",
165
+ });
166
+ }
167
+
168
+ async deleteView(event) {
169
+ event.preventDefault();
170
+ const viewId = this.submenuTarget.dataset.viewId;
171
+ const viewEl = this.viewTargets.find((el) => el.dataset.viewId === viewId);
172
+ const viewName = viewEl?.dataset.viewName || "this view";
173
+
174
+ this._closeSubmenu();
175
+ this._closeDropdown();
176
+
177
+ if (!confirm(`Delete "${viewName}"?`)) return;
178
+
179
+ const turboFrameId = this._turboFrameId();
180
+ await destroy(`${this.viewsUrlValue}/${viewId}`, {
181
+ body: JSON.stringify({ turbo_frame_id: turboFrameId }),
182
+ contentType: "application/json",
183
+ responseKind: "turbo-stream",
184
+ });
185
+ }
186
+
187
+ // Called from outside (e.g. after a view is saved/updated) to update the trigger label
188
+ updateSelectedView(viewId, viewName) {
189
+ this.viewTargets.forEach((el) => {
190
+ const check = el.querySelector(".mensa-table__views__option-check");
191
+ check?.classList.toggle("invisible", el.dataset.viewId !== viewId);
192
+ });
193
+ if (this.hasTriggerLabelTarget && viewName) {
194
+ this.triggerLabelTarget.textContent = viewName;
195
+ }
196
+ }
197
+
198
+ // --- private ---
199
+
200
+ _openDropdown() {
201
+ this.dropdownTarget.classList.remove("hidden");
202
+ this._bindOutsideClick();
203
+ }
204
+
205
+ _closeDropdown() {
206
+ this.dropdownTarget.classList.add("hidden");
207
+ this._closeSubmenu();
208
+ this._unbindOutsideClick();
209
+ }
210
+
211
+ _closeSubmenu() {
212
+ this.submenuTarget.classList.add("hidden");
213
+ this._activeSubmenuViewId = null;
214
+ }
215
+
216
+ _closeRenameDialog() {
217
+ if (!this.hasRenameDialogTarget) return;
218
+ if (typeof this.renameDialogTarget.close === "function") {
219
+ this.renameDialogTarget.close();
220
+ } else {
221
+ this.renameDialogTarget.removeAttribute("open");
222
+ }
223
+ }
224
+
225
+ _turboFrameId() {
226
+ const root = this.element.closest(".mensa-table");
227
+ const frame = root?.querySelector("turbo-frame");
228
+ return frame?.id || this.tableIdValue || "";
229
+ }
230
+
231
+ _bindOutsideClick() {
232
+ this._unbindOutsideClick();
233
+ this._outsideClickHandler = (e) => {
234
+ if (!this.element.contains(e.target)) {
235
+ this._closeDropdown();
236
+ }
237
+ };
238
+ setTimeout(() => document.addEventListener("click", this._outsideClickHandler), 0);
239
+ }
240
+
241
+ _unbindOutsideClick() {
242
+ if (this._outsideClickHandler) {
243
+ document.removeEventListener("click", this._outsideClickHandler);
244
+ this._outsideClickHandler = null;
245
+ }
246
+ }
247
+ }