yummy-guide-generic-administrate 0.1.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +4 -0
  3. data/README.md +243 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/javascripts/yummy_guide_administrate/filter_form.js +219 -0
  6. data/app/assets/javascripts/yummy_guide_administrate/sticky_left_columns.js +141 -0
  7. data/app/assets/stylesheets/yummy_guide_administrate/components.scss +199 -0
  8. data/app/controllers/concerns/yummy_guide/administrate/datetime_filter_parameters.rb +40 -0
  9. data/app/controllers/concerns/yummy_guide/administrate/default_sorting.rb +46 -0
  10. data/app/dashboards/yummy_guide/administrate/application_dashboard.rb +46 -0
  11. data/app/fields/yummy_guide/administrate/fields/area/picture_field.rb +106 -0
  12. data/app/fields/yummy_guide/administrate/fields/json_pretty_field.rb +29 -0
  13. data/app/fields/yummy_guide/administrate/fields/version_item_field.rb +62 -0
  14. data/app/fields/yummy_guide/administrate/fields/version_whodunnit_field.rb +65 -0
  15. data/app/helpers/yummy_guide/administrate/collection_helper.rb +62 -0
  16. data/app/helpers/yummy_guide/administrate/filter_form_helper.rb +80 -0
  17. data/app/views/fields/yummy_guide_administrate/area/picture/_form.html.erb +36 -0
  18. data/app/views/fields/yummy_guide_administrate/area/picture/_index.html.erb +2 -0
  19. data/app/views/fields/yummy_guide_administrate/area/picture/_show.html.erb +24 -0
  20. data/app/views/fields/yummy_guide_administrate/json_pretty_field/_index.html.erb +2 -0
  21. data/app/views/fields/yummy_guide_administrate/json_pretty_field/_show.html.erb +6 -0
  22. data/app/views/fields/yummy_guide_administrate/version_item_field/_index.html.erb +2 -0
  23. data/app/views/fields/yummy_guide_administrate/version_item_field/_show.html.erb +6 -0
  24. data/app/views/fields/yummy_guide_administrate/version_whodunnit_field/_index.html.erb +2 -0
  25. data/app/views/fields/yummy_guide_administrate/version_whodunnit_field/_show.html.erb +6 -0
  26. data/app/views/yummy_guide/administrate/administrate/application/_collection.html.erb +62 -0
  27. data/app/views/yummy_guide/administrate/filter_forms/_checkbox_group.html.erb +30 -0
  28. data/app/views/yummy_guide/administrate/filter_forms/_datetime_field.html.erb +30 -0
  29. data/app/views/yummy_guide/administrate/filter_forms/_frame.html.erb +28 -0
  30. data/lib/generic/administrate.rb +3 -0
  31. data/lib/yummy_guide/administrate/engine.rb +18 -0
  32. data/lib/yummy_guide/administrate/version.rb +8 -0
  33. data/lib/yummy_guide/administrate.rb +13 -0
  34. data/spec/spec_helper.rb +25 -0
  35. data/spec/yummy_guide/administrate/application_dashboard_spec.rb +39 -0
  36. data/spec/yummy_guide/administrate/collection_helper_spec.rb +25 -0
  37. data/spec/yummy_guide/administrate/datetime_filter_parameters_spec.rb +52 -0
  38. data/spec/yummy_guide/administrate/fields/json_pretty_field_spec.rb +22 -0
  39. data/spec/yummy_guide/administrate/fields/version_item_field_spec.rb +30 -0
  40. data/spec/yummy_guide/administrate/fields/version_whodunnit_field_spec.rb +31 -0
  41. data/spec/yummy_guide/administrate/filter_form_helper_spec.rb +40 -0
  42. data/yummy-guide-generic-administrate.gemspec +38 -0
  43. metadata +156 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b78001f457aaa653797c44c84f1d169f7e870932e5b7ea43ae1d3994ecab9747
4
+ data.tar.gz: 437ffe942299cbda22d3eca1e72ecc625069880862848b94d3da3520cb3ecf12
5
+ SHA512:
6
+ metadata.gz: 8a8b10627365f4b549f43701974d3e8ab0066d6a656bdab19fbe310dce3ed258b1b80e58db16ce72219bda72030f9269fd71a752324a016cc44426464d64d0c4
7
+ data.tar.gz: 589d405d80686573b6fd8efd2a45795861e010203b703f85e1a0c142dec044c8cf603189480f1adfd581c8fc24c61690a56335c1b487d3be869322c7644bdc91
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
data/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # yummy-guide-generic-administrate
2
+
3
+ `yummy-guide-generic-administrate` は、Yummy Guide 系 Rails アプリで共通利用する
4
+ [`Administrate`](https://github.com/thoughtbot/administrate) 拡張をまとめた Rails
5
+ Engine です。
6
+
7
+ `Administrate` 本体を置き換えるものではなく、ダッシュボードの共通既定値、
8
+ フィルター UI、一覧表示 helper、共通 field などを再利用しやすくするための補助
9
+ gem です。
10
+
11
+ ## 前提
12
+
13
+ - Ruby `>= 3.2.2`
14
+ - Rails `>= 7.0`, `< 7.2`
15
+ - Administrate `>= 0.19`, `< 0.21`
16
+ - `sprockets-rails`
17
+
18
+ ## インストール
19
+
20
+ `Gemfile` に追加します。
21
+
22
+ ```ruby
23
+ gem "yummy-guide-generic-administrate"
24
+ ```
25
+
26
+ その後、依存 gem をインストールします。
27
+
28
+ ```bash
29
+ bundle install
30
+ ```
31
+
32
+ ## 提供するもの
33
+
34
+ - `YummyGuide::Administrate::ApplicationDashboard`
35
+ - 一覧画面の既定ソートと固定列数の共通基底クラス
36
+ - `YummyGuide::Administrate::DefaultSorting`
37
+ - dashboard 側の既定ソート設定を controller に反映する concern
38
+ - `YummyGuide::Administrate::DatetimeFilterParameters`
39
+ - 日付、時、分の分割パラメータを 1 つの datetime 文字列へ正規化する concern
40
+ - `YummyGuide::Administrate::CollectionHelper`
41
+ - 一覧テーブルの固定列数、リンク生成、action partial 解決を補助する helper
42
+ - `YummyGuide::Administrate::FilterFormHelper`
43
+ - datetime フィルターや checkbox group の組み立てを補助する helper
44
+ - 共通 partial / assets
45
+ - collection partial
46
+ - filter form partial
47
+ - `filter_form.js`
48
+ - `sticky_left_columns.js`
49
+ - `components.css`
50
+ - 共通 field
51
+ - `YummyGuide::Administrate::Fields::JsonPrettyField`
52
+ - `YummyGuide::Administrate::Fields::VersionItemField`
53
+ - `YummyGuide::Administrate::Fields::VersionWhodunnitField`
54
+ - `YummyGuide::Administrate::Fields::Area::PictureField`
55
+
56
+ ## 利用方法
57
+
58
+ ### Dashboard の基底クラス
59
+
60
+ 共通の既定ソートと固定列数設定を使う場合は、dashboard を
61
+ `YummyGuide::Administrate::ApplicationDashboard` から継承します。
62
+
63
+ ```ruby
64
+ class ApplicationDashboard < YummyGuide::Administrate::ApplicationDashboard
65
+ end
66
+ ```
67
+
68
+ 必要に応じて各 dashboard 側で上書きできます。
69
+
70
+ ```ruby
71
+ class Admin::ArticleDashboard < ApplicationDashboard
72
+ COLLECTION_ATTRIBUTES = %i[id title status created_at].freeze
73
+ COLLECTION_SORTABLE_ATTRIBUTES = %i[id title status created_at].freeze
74
+ INDEX_FIXED_COLUMNS_COUNT = 2
75
+
76
+ def default_sorting_attribute
77
+ :published_at
78
+ end
79
+
80
+ def default_sorting_direction
81
+ :desc
82
+ end
83
+ end
84
+ ```
85
+
86
+ ### Controller concern / helper
87
+
88
+ 管理画面の基底 controller で共通 concern と helper を読み込みます。
89
+
90
+ ```ruby
91
+ class Admin::ApplicationController < Administrate::ApplicationController
92
+ include YummyGuide::Administrate::DefaultSorting
93
+ include YummyGuide::Administrate::DatetimeFilterParameters
94
+
95
+ helper YummyGuide::Administrate::CollectionHelper
96
+ helper YummyGuide::Administrate::FilterFormHelper
97
+ end
98
+ ```
99
+
100
+ ### Collection partial
101
+
102
+ ホストアプリ側の `app/views/administrate/application/_collection.html.erb` から、
103
+ engine の共通 partial に委譲します。
104
+
105
+ ```erb
106
+ <%= render "yummy_guide/administrate/administrate/application/collection",
107
+ collection_presenter: collection_presenter,
108
+ page: page,
109
+ resources: resources,
110
+ table_title: table_title,
111
+ namespace: namespace,
112
+ resource_class: resource_class,
113
+ collection_field_name: collection_field_name %>
114
+ ```
115
+
116
+ ### Datetime filter
117
+
118
+ filter form の枠と datetime 入力 partial を組み合わせて利用できます。
119
+
120
+ ```erb
121
+ <%= render "yummy_guide/administrate/filter_forms/frame",
122
+ path: path,
123
+ form: form,
124
+ method: method,
125
+ current_values: search_options do |f, values| %>
126
+ <tr>
127
+ <td><%= f.label :start_at, "開始日時" %></td>
128
+ <td>
129
+ <%= render "yummy_guide/administrate/filter_forms/datetime_field",
130
+ form_scope: form,
131
+ field_name: :start_at,
132
+ current_value: values["start_at"],
133
+ css_class: "#{form}_start_at" %>
134
+ </td>
135
+ </tr>
136
+ <% end %>
137
+ ```
138
+
139
+ controller 側では、必要な filter key を指定して datetime パラメータを正規化します。
140
+
141
+ ```ruby
142
+ def search_term
143
+ resource_params = params[resource_name] || params[resource_name.to_sym] || {}
144
+ raw_filters = resource_params[:search] || resource_params["search"] || {}
145
+
146
+ normalized_filters = normalize_datetime_filter_params(
147
+ raw_filters,
148
+ keys: %i[start_at end_at]
149
+ )
150
+
151
+ normalized_filters
152
+ end
153
+ ```
154
+
155
+ ### Asset の読み込み
156
+
157
+ この engine の asset はホストアプリ側で明示的に読み込んでください。
158
+
159
+ ```js
160
+ //= require yummy_guide_administrate/filter_form
161
+ //= require yummy_guide_administrate/sticky_left_columns
162
+ ```
163
+
164
+ ```scss
165
+ *= require yummy_guide_administrate/components
166
+ ```
167
+
168
+ ### Custom field
169
+
170
+ dashboard から共通 field を利用できます。
171
+
172
+ ```ruby
173
+ ATTRIBUTE_TYPES = {
174
+ metadata: YummyGuide::Administrate::Fields::JsonPrettyField,
175
+ item: YummyGuide::Administrate::Fields::VersionItemField.with_options(namespace: :admin),
176
+ whodunnit: YummyGuide::Administrate::Fields::VersionWhodunnitField.with_options(
177
+ namespace: :admin,
178
+ user_class: "User"
179
+ ),
180
+ pictures: YummyGuide::Administrate::Fields::Area::PictureField
181
+ }.freeze
182
+ ```
183
+
184
+ ## 各 field の用途
185
+
186
+ ### JsonPrettyField
187
+
188
+ JSON 文字列または JSON 互換オブジェクトを、整形済みの文字列として表示します。
189
+
190
+ ### VersionItemField
191
+
192
+ PaperTrail の `item` や `reify` 結果をもとに、対象 resource のラベルと詳細画面への
193
+ リンクを表示します。`namespace` は `:admin` が既定です。
194
+
195
+ ### VersionWhodunnitField
196
+
197
+ PaperTrail の `whodunnit` から操作ユーザーを解決して表示します。
198
+
199
+ - 既定では `User` クラスを参照します
200
+ - `user_class` で別クラスを指定できます
201
+ - `user_label` に proc を渡すと表示ラベルを上書きできます
202
+
203
+ ### Area::PictureField
204
+
205
+ 画像添付用の field です。Active Storage の添付オブジェクト、または `attachments`
206
+ を返すオブジェクトを前提としています。
207
+
208
+ 必要に応じて以下の option を指定できます。
209
+
210
+ - `max_uploads`
211
+ - `input_name`
212
+ - `purge_input_name`
213
+ - `attachment_url`
214
+ - `preview_url`
215
+
216
+ ## 参照
217
+
218
+ - Administrate 公式リポジトリ
219
+ - <https://github.com/thoughtbot/administrate>
220
+ - Administrate gem ページ
221
+ - <https://rubygems.org/gems/administrate>
222
+
223
+ ## リリース手順
224
+
225
+ このリポジトリは `bundler/gem_tasks` を利用しているため、標準の gem リリースタスク
226
+ で公開できます。
227
+
228
+ 1. `lib/yummy_guide/administrate/version.rb` の `VERSION` を更新する
229
+ 2. 必要な変更を commit 済みの状態にする
230
+ 3. 必要に応じて `bundle exec rake spec` で確認する
231
+ 4. `bundle exec rake release` を実行する
232
+
233
+ `bundle exec rake release` を実行すると、現在の `VERSION` をもとに `v<version>` の
234
+ git tag を作成し、gem を build して `rubygems.org` へ push します。
235
+
236
+ 事前に RubyGems への push 権限があること、ローカル環境で `gem push` が利用できる
237
+ ことを確認してください。
238
+
239
+ ## 注意点
240
+
241
+ - asset は precompile 対象に追加されますが、ホストアプリ側での読み込み設定は別途必要です
242
+ - `VersionWhodunnitField` は対象ユーザー class や表示ラベルをアプリ事情に合わせて調整してください
243
+ - `Area::PictureField` の URL 解決は、必要なら option で明示的に上書きしてください
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ begin
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+ task default: :spec
8
+ rescue LoadError
9
+ task default: :test
10
+ end
11
+
@@ -0,0 +1,219 @@
1
+ (function() {
2
+ var FORM_SELECTOR = "[data-yummy-guide-administrate-filter-form]";
3
+
4
+ function initializeForms(root) {
5
+ var forms = [];
6
+
7
+ if (root.matches && root.matches(FORM_SELECTOR)) {
8
+ forms.push(root);
9
+ }
10
+
11
+ if (root.querySelectorAll) {
12
+ root.querySelectorAll(FORM_SELECTOR).forEach(function(formEl) {
13
+ forms.push(formEl);
14
+ });
15
+ }
16
+
17
+ forms.forEach(initializeForm);
18
+ }
19
+
20
+ function initializeForm(formEl) {
21
+ if (formEl.dataset.ygAdministrateFilterFormInitialized === "true") {
22
+ syncDatetimeFilterFields(formEl);
23
+ return;
24
+ }
25
+
26
+ formEl.dataset.ygAdministrateFilterFormInitialized = "true";
27
+ formEl.addEventListener("submit", function() {
28
+ syncDatetimeFilterFields(formEl);
29
+ });
30
+
31
+ formEl.querySelectorAll("[data-datetime-filter]").forEach(function(groupEl) {
32
+ groupEl.addEventListener("change", function(event) {
33
+ handleDatetimeFilterChange(formEl, groupEl, event);
34
+ });
35
+
36
+ syncBlankMinuteOptionState(groupEl);
37
+ syncDatetimeTimeDisabledState(groupEl);
38
+ });
39
+
40
+ formEl.querySelectorAll('[data-behavior="filter-form-clear"]').forEach(function(buttonEl) {
41
+ buttonEl.addEventListener("click", function() {
42
+ clearFormFields(formEl);
43
+ });
44
+ });
45
+
46
+ formEl.querySelectorAll('[data-behavior="checkbox-group-select-all"]').forEach(function(buttonEl) {
47
+ buttonEl.addEventListener("click", function() {
48
+ setCheckboxGroupState(formEl, buttonEl.dataset.target, true);
49
+ });
50
+ });
51
+
52
+ formEl.querySelectorAll('[data-behavior="checkbox-group-clear-all"]').forEach(function(buttonEl) {
53
+ buttonEl.addEventListener("click", function() {
54
+ setCheckboxGroupState(formEl, buttonEl.dataset.target, false);
55
+ });
56
+ });
57
+
58
+ syncDatetimeFilterFields(formEl);
59
+ }
60
+
61
+ function clearFormFields(formEl) {
62
+ formEl.querySelectorAll("input, select, textarea").forEach(function(fieldEl) {
63
+ if (fieldEl.disabled || fieldEl.type === "hidden" || fieldEl.type === "submit") {
64
+ return;
65
+ }
66
+
67
+ if (fieldEl.tagName === "SELECT") {
68
+ fieldEl.value = "";
69
+ return;
70
+ }
71
+
72
+ if (fieldEl.type === "checkbox" || fieldEl.type === "radio") {
73
+ fieldEl.checked = false;
74
+ return;
75
+ }
76
+
77
+ fieldEl.value = "";
78
+ });
79
+
80
+ formEl.querySelectorAll("[data-datetime-filter]").forEach(function(groupEl) {
81
+ clearDatetimeTimeParts(groupEl);
82
+ syncDatetimeTimeDisabledState(groupEl);
83
+ syncBlankMinuteOptionState(groupEl);
84
+ });
85
+
86
+ syncDatetimeFilterFields(formEl);
87
+ }
88
+
89
+ function setCheckboxGroupState(formEl, groupName, checked) {
90
+ if (!groupName) return;
91
+
92
+ formEl.querySelectorAll('[data-checkbox-group-item="' + groupName + '"]').forEach(function(checkboxEl) {
93
+ if (!checkboxEl.disabled) {
94
+ checkboxEl.checked = checked;
95
+ }
96
+ });
97
+ }
98
+
99
+ function handleDatetimeFilterChange(formEl, groupEl, event) {
100
+ if (event.target && event.target.dataset.datetimePart === "date") {
101
+ if (event.target.value) {
102
+ syncRangeEndDate(formEl, groupEl);
103
+ } else {
104
+ clearDatetimeTimeParts(groupEl);
105
+ }
106
+ }
107
+
108
+ if (event.target && event.target.dataset.datetimePart === "hour") {
109
+ syncDatetimeMinuteOnHourChange(groupEl);
110
+ }
111
+
112
+ if (event.target && event.target.dataset.datetimePart === "minute") {
113
+ preventBlankDatetimeMinute(groupEl);
114
+ }
115
+
116
+ syncDatetimeTimeDisabledState(groupEl);
117
+ syncBlankMinuteOptionState(groupEl);
118
+ syncDatetimeFilterFields(formEl);
119
+ }
120
+
121
+ function syncRangeEndDate(formEl, groupEl) {
122
+ var endTarget = groupEl.dataset.datetimeEndTarget;
123
+ if (!endTarget) return;
124
+
125
+ var startDateEl = groupEl.querySelector('[data-datetime-part="date"]');
126
+ if (!startDateEl || !startDateEl.value) return;
127
+
128
+ var endGroupEl = formEl.querySelector('[data-datetime-field="' + endTarget + '"]');
129
+ if (!endGroupEl) return;
130
+
131
+ var endDateEl = endGroupEl.querySelector('[data-datetime-part="date"]');
132
+ if (!endDateEl || endDateEl.value) return;
133
+
134
+ endDateEl.value = startDateEl.value;
135
+ }
136
+
137
+ function clearDatetimeTimeParts(groupEl) {
138
+ var hourEl = groupEl.querySelector('[data-datetime-part="hour"]');
139
+ var minuteEl = groupEl.querySelector('[data-datetime-part="minute"]');
140
+
141
+ if (hourEl) hourEl.value = "";
142
+ if (minuteEl) minuteEl.value = "";
143
+ }
144
+
145
+ function syncDatetimeMinuteOnHourChange(groupEl) {
146
+ var hourEl = groupEl.querySelector('[data-datetime-part="hour"]');
147
+ var minuteEl = groupEl.querySelector('[data-datetime-part="minute"]');
148
+ if (!hourEl || !minuteEl) return;
149
+
150
+ minuteEl.value = hourEl.value ? "00" : "";
151
+ }
152
+
153
+ function preventBlankDatetimeMinute(groupEl) {
154
+ var hourEl = groupEl.querySelector('[data-datetime-part="hour"]');
155
+ var minuteEl = groupEl.querySelector('[data-datetime-part="minute"]');
156
+ if (!hourEl || !minuteEl) return;
157
+
158
+ if (hourEl.value && minuteEl.value === "") {
159
+ minuteEl.value = "00";
160
+ }
161
+ }
162
+
163
+ function syncBlankMinuteOptionState(groupEl) {
164
+ var hourEl = groupEl.querySelector('[data-datetime-part="hour"]');
165
+ var minuteEl = groupEl.querySelector('[data-datetime-part="minute"]');
166
+ if (!hourEl || !minuteEl) return;
167
+
168
+ var blankOptionEl = minuteEl.querySelector('option[value=""]');
169
+ if (blankOptionEl) {
170
+ blankOptionEl.disabled = !!hourEl.value;
171
+ }
172
+ }
173
+
174
+ function syncDatetimeTimeDisabledState(groupEl) {
175
+ var dateEl = groupEl.querySelector('[data-datetime-part="date"]');
176
+ var hourEl = groupEl.querySelector('[data-datetime-part="hour"]');
177
+ var minuteEl = groupEl.querySelector('[data-datetime-part="minute"]');
178
+ var dateDisabled = !dateEl || !dateEl.value;
179
+ var minuteDisabled = dateDisabled || !hourEl || !hourEl.value;
180
+
181
+ if (hourEl) hourEl.disabled = dateDisabled;
182
+ if (minuteEl) minuteEl.disabled = minuteDisabled;
183
+ }
184
+
185
+ function syncDatetimeFilterFields(formEl) {
186
+ formEl.querySelectorAll("[data-datetime-filter]").forEach(function(groupEl) {
187
+ var combinedEl = groupEl.querySelector('[data-datetime-part="combined"]');
188
+ var dateEl = groupEl.querySelector('[data-datetime-part="date"]');
189
+ var hourEl = groupEl.querySelector('[data-datetime-part="hour"]');
190
+ var minuteEl = groupEl.querySelector('[data-datetime-part="minute"]');
191
+
192
+ if (!combinedEl || !dateEl || !hourEl || !minuteEl) return;
193
+
194
+ syncDatetimeTimeDisabledState(groupEl);
195
+
196
+ if (!dateEl.value) {
197
+ combinedEl.value = "";
198
+ combinedEl.setAttribute("value", "");
199
+ return;
200
+ }
201
+
202
+ combinedEl.value = hourEl.value && minuteEl.value ? dateEl.value + "T" + hourEl.value + ":" + minuteEl.value : dateEl.value;
203
+ combinedEl.setAttribute("value", combinedEl.value);
204
+ });
205
+ }
206
+
207
+ function initializeFromDocument() {
208
+ initializeForms(document);
209
+ }
210
+
211
+ if (document.readyState === "loading") {
212
+ document.addEventListener("DOMContentLoaded", initializeFromDocument, { once: true });
213
+ } else {
214
+ initializeFromDocument();
215
+ }
216
+
217
+ document.addEventListener("turbo:load", initializeFromDocument);
218
+ })();
219
+
@@ -0,0 +1,141 @@
1
+ (function() {
2
+ var TABLE_SELECTOR = "table[data-fixed-columns-count]";
3
+ var resizeObservers = new WeakMap();
4
+
5
+ function directCells(row) {
6
+ return Array.from(row.children).filter(function(cell) {
7
+ return cell.tagName === "TH" || cell.tagName === "TD";
8
+ });
9
+ }
10
+
11
+ function preciseNumber(value) {
12
+ return Math.round(value * 1000) / 1000;
13
+ }
14
+
15
+ function measuredWidth(element) {
16
+ if (!element) return 0;
17
+
18
+ var rectWidth = element.getBoundingClientRect().width;
19
+ return preciseNumber(rectWidth || element.offsetWidth || 0);
20
+ }
21
+
22
+ function cssPixelValue(value) {
23
+ return preciseNumber(value) + "px";
24
+ }
25
+
26
+ function resetStickyColumns(table) {
27
+ table.querySelectorAll(".sticky-left").forEach(function(cell) {
28
+ cell.classList.remove("sticky-left", "sticky-left--last");
29
+ cell.style.removeProperty("left");
30
+ });
31
+ }
32
+
33
+ function fixedColumnsCount(table) {
34
+ var parsedCount = parseInt(table.dataset.fixedColumnsCount || "0", 10);
35
+ return Number.isNaN(parsedCount) ? 0 : Math.max(parsedCount, 0);
36
+ }
37
+
38
+ function stickyOffsets(table, count) {
39
+ var headerRow = table.querySelector("thead tr");
40
+ if (!headerRow) return [];
41
+
42
+ var widths = directCells(headerRow).slice(0, count).map(function(cell) {
43
+ return measuredWidth(cell);
44
+ });
45
+
46
+ if (!widths.some(Boolean)) {
47
+ Array.from(table.querySelectorAll("tbody tr, tfoot tr")).some(function(row) {
48
+ var cells = directCells(row);
49
+ if (cells.length < count || cells.some(function(cell) { return cell.colSpan > 1; })) return false;
50
+
51
+ widths = cells.slice(0, count).map(function(cell) {
52
+ return measuredWidth(cell);
53
+ });
54
+ return widths.some(Boolean);
55
+ });
56
+ }
57
+
58
+ var offsets = [];
59
+ var currentLeft = 0;
60
+
61
+ widths.forEach(function(width) {
62
+ offsets.push(currentLeft);
63
+ currentLeft += width;
64
+ });
65
+
66
+ return offsets;
67
+ }
68
+
69
+ function applyStickyColumns(table) {
70
+ resetStickyColumns(table);
71
+
72
+ var count = fixedColumnsCount(table);
73
+ if (count === 0) return;
74
+
75
+ var offsets = stickyOffsets(table, count);
76
+ if (offsets.length === 0) return;
77
+
78
+ table.querySelectorAll("thead tr, tbody tr, tfoot tr").forEach(function(row) {
79
+ var cells = directCells(row);
80
+ if (cells.length === 0 || cells.some(function(cell) { return cell.colSpan > 1; })) return;
81
+
82
+ cells.slice(0, offsets.length).forEach(function(cell, index) {
83
+ cell.classList.add("sticky-left");
84
+ if (index === offsets.length - 1) {
85
+ cell.classList.add("sticky-left--last");
86
+ }
87
+ cell.style.left = cssPixelValue(offsets[index]);
88
+ });
89
+ });
90
+ }
91
+
92
+ function observeStickyColumns(table) {
93
+ if (!window.ResizeObserver || resizeObservers.has(table)) return;
94
+
95
+ var observer = new ResizeObserver(function() {
96
+ window.requestAnimationFrame(function() {
97
+ applyStickyColumns(table);
98
+ });
99
+ });
100
+
101
+ observer.observe(table);
102
+ if (table.parentElement) {
103
+ observer.observe(table.parentElement);
104
+ }
105
+
106
+ resizeObservers.set(table, observer);
107
+ }
108
+
109
+ function initializeStickyColumns(root) {
110
+ var tables = [];
111
+
112
+ if (root.matches && root.matches(TABLE_SELECTOR)) {
113
+ tables.push(root);
114
+ }
115
+
116
+ if (root.querySelectorAll) {
117
+ root.querySelectorAll(TABLE_SELECTOR).forEach(function(table) {
118
+ tables.push(table);
119
+ });
120
+ }
121
+
122
+ tables.forEach(function(table) {
123
+ applyStickyColumns(table);
124
+ observeStickyColumns(table);
125
+ });
126
+ }
127
+
128
+ function initializeFromDocument() {
129
+ initializeStickyColumns(document);
130
+ }
131
+
132
+ if (document.readyState === "loading") {
133
+ document.addEventListener("DOMContentLoaded", initializeFromDocument, { once: true });
134
+ } else {
135
+ initializeFromDocument();
136
+ }
137
+
138
+ document.addEventListener("turbo:load", initializeFromDocument);
139
+ window.addEventListener("resize", initializeFromDocument);
140
+ })();
141
+