yummy-guide-generic-administrate 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 387008d1b76513f348890946b449fcc46cdbb4ed61f594378786288baf0e74b7
4
- data.tar.gz: 672c758ae345bcee1b7726e41bca7f6e7d77b9b5d2f52896ffd5514095d96cf6
3
+ metadata.gz: f3caffae44b6ea10fc063a2cc8fb8a3938e3cc42078c4af3bc2a08d4188cf01a
4
+ data.tar.gz: cea155304d262445a29199f9c5e57c574600508dca66206f49b81b3d3db66fb2
5
5
  SHA512:
6
- metadata.gz: dbcf02d875cf41c10c080df757669320a22b9af01377848652445fac1ece7d1473f4c32f465c3c09a871702a3a77b3b28fc7883456f4b3ee25fa393566ab796f
7
- data.tar.gz: 32b6e0fa25d8c7f5619c021be719cc28e74aa22c7c6f5a2220c0e5397d0589c52ffa26bdb9bb8e4d85213e8f342eb57fe0460ca7d6cc951b54ae552f24c39e1f
6
+ metadata.gz: 6a2e5a4b98e6cea96bfdc9e0cb026212c6ed37f39518800368329cc8ba1fcff202859acb8b3340754b493544b92f33e191d92606de032575c889e436ae39b789
7
+ data.tar.gz: 669b6a383ff6d184527e7324def8875626dc46dd0b5d9c5d55d3b5f18fd8f4e97188d6a943ffa65e4076a70eefd0389e41abbd7b77d97f9e98466a3c580ecc43
data/README.md CHANGED
@@ -41,6 +41,8 @@ bundle install
41
41
  - 一覧テーブルの固定列数、リンク生成、action partial 解決を補助する helper
42
42
  - `YummyGuide::Administrate::FilterFormHelper`
43
43
  - datetime フィルターや checkbox group の組み立てを補助する helper
44
+ - `YummyGuide::Administrate::FilterControlsHelper`
45
+ - dashboard の Field 型フィルター定義から Filter ボタンとモーダルフォームを描画する helper
44
46
  - `YummyGuide::Administrate::DatetimeInputHelper`
45
47
  - 管理画面フォーム用の date + time 入力 helper
46
48
  - `YummyGuide::Administrate::NumberInputHelper`
@@ -52,6 +54,7 @@ bundle install
52
54
  - `clipboards.js`
53
55
  - `datetime_input.js`
54
56
  - `fixed_submit_actions.js`
57
+ - `filter_controls.js`
55
58
  - `filter_form.js`
56
59
  - `sticky_left_columns.js`
57
60
  - `sticky_table_headers.js`
@@ -103,6 +106,7 @@ class Admin::ApplicationController < Administrate::ApplicationController
103
106
 
104
107
  helper YummyGuide::Administrate::CollectionHelper
105
108
  helper YummyGuide::Administrate::DatetimeInputHelper
109
+ helper YummyGuide::Administrate::FilterControlsHelper
106
110
  helper YummyGuide::Administrate::FilterFormHelper
107
111
  helper YummyGuide::Administrate::NumberInputHelper
108
112
  end
@@ -300,6 +304,140 @@ def search_term
300
304
  end
301
305
  ```
302
306
 
307
+ ### Dashboard driven filter controls
308
+
309
+ dashboard に `FILTER_ATTRIBUTES` を定義すると、`admin_filter_controls` で既存の
310
+ Administrate index header に Filter ボタンとモーダルフォームを描画できます。
311
+
312
+ #### Filter の設定方法
313
+
314
+ 1. dashboard に送信先と filter 対象項目を定義します。
315
+
316
+ ```ruby
317
+ class AdspendDashboard < ApplicationDashboard
318
+ FILTER_PATH = ->(view, _locals) { view.admin_adspends_path }
319
+ FILTER_CLEAR_PATH = ->(view, _locals) { view.admin_adspends_path }
320
+
321
+ FILTER_ATTRIBUTES = {
322
+ campaign_name: YummyGuide::Administrate::Filters::Text.with_options(label: "Campaign name"),
323
+ yearmonth: YummyGuide::Administrate::Filters::Text.with_options(
324
+ label: "Year month",
325
+ inputmode: "numeric",
326
+ pattern: "\\d{6}",
327
+ placeholder: "YYYYMM"
328
+ )
329
+ }.freeze
330
+ end
331
+ ```
332
+
333
+ 2. index header などで `admin_filter_controls` を呼び出します。`page` から dashboard を
334
+ 解決し、`FILTER_ATTRIBUTES` が存在する場合だけ Filter ボタンとフォームを描画します。
335
+
336
+ ```erb
337
+ <%= admin_filter_controls(
338
+ page: page,
339
+ search_options: search_options
340
+ ) %>
341
+ ```
342
+
343
+ 送信先は helper の `path:` / `clear_path:` で明示することもできます。helper の指定は
344
+ dashboard の `FILTER_PATH` / `FILTER_CLEAR_PATH` より優先されます。
345
+
346
+ ```erb
347
+ <%= admin_filter_controls(
348
+ page: page,
349
+ path: admin_adspends_path,
350
+ clear_path: admin_adspends_path,
351
+ search_options: search_options,
352
+ filter_locals: { show_discarded_filter: false },
353
+ root_hidden_fields: { "reservation[order]" => current_order[:order] },
354
+ extra_actions: [button_tag("Download PDF", type: "button", class: "button")]
355
+ ) %>
356
+ ```
357
+
358
+ 標準 Field は `Text`, `Select`, `Checkbox`, `RadioGroup`, `CheckboxGroup`,
359
+ `DateRange`, `DatetimeRange`, `DatetimeLocalRange`, `Custom` です。
360
+ 主な option は次のとおりです。
361
+
362
+ - 共通: `label`, `default`, `if`, `class`, `id`, `placeholder`, `inputmode`, `pattern`
363
+ - 選択系: `collection` または `options`, `select_options`
364
+ - checkbox: `checked_value`, `unchecked_value`
365
+ - checkbox group: `group`
366
+ - range: `from`, `to`, `from_default`, `to_default`, `css_class`
367
+
368
+ `label`, `collection`, `default`, `if` などの option には Proc も指定できます。Proc は
369
+ arity に応じて `call`, `call(view_context)`, `call(view_context, filter_locals)` のいずれかで評価されます。
370
+
371
+ ```ruby
372
+ FILTER_ATTRIBUTES = {
373
+ owner_id: YummyGuide::Administrate::Filters::Text.with_options(
374
+ label: "Owner ID",
375
+ inputmode: "numeric",
376
+ if: ->(view, _locals) { !view.current_user&.owner? }
377
+ ),
378
+ status: YummyGuide::Administrate::Filters::Select.with_options(
379
+ label: "Status",
380
+ collection: ->(_view, locals) { locals[:status_collection] },
381
+ select_options: { include_blank: true }
382
+ )
383
+ }.freeze
384
+ ```
385
+
386
+ #### カスタム Filter の作成方法
387
+
388
+ 既存の Field 型で表現できない場合は、partial を使う方法と独自クラスを定義する方法があります。
389
+
390
+ partial だけで足りる場合は `Custom` を使います。partial には `form`, `form_scope`,
391
+ `field`, `current_values`, `filter_locals` が渡されます。
392
+
393
+ ```ruby
394
+ FILTER_ATTRIBUTES = {
395
+ price_range: YummyGuide::Administrate::Filters::Custom.with_options(
396
+ partial: "admin/filters/price_range"
397
+ )
398
+ }.freeze
399
+ ```
400
+
401
+ ```erb
402
+ <tr>
403
+ <td><%= form.label field.name, field.label_text(self, filter_locals) %></td>
404
+ <td>
405
+ <%= form.number_field :min_price, value: current_values["min_price"] %>
406
+ <%= form.number_field :max_price, value: current_values["max_price"] %>
407
+ </td>
408
+ <%= filter_field_clear_cell if respond_to?(:filter_field_clear_cell) %>
409
+ </tr>
410
+ ```
411
+
412
+ `filter_form.js` を読み込んでいる場合、`data-behavior="filter-field-clear"` を持つ
413
+ button は同じ `tr` 内の input / select / textarea / checkbox / radio / datetime
414
+ filter だけをクリアします。
415
+
416
+ 複数画面で再利用する filter 型は `YummyGuide::Administrate::Filters::Base` を継承して作ります。
417
+ 単一 input なら `input` を実装し、行全体を制御したい場合は `row` または `input_cell` を上書きします。
418
+
419
+ ```ruby
420
+ class CurrencyFilter < YummyGuide::Administrate::Filters::Base
421
+ private
422
+
423
+ def input(view_context, form, current_values, locals)
424
+ form.select(
425
+ name,
426
+ view_context.options_for_select(options.fetch(:collection), current_value(current_values)),
427
+ { include_blank: true },
428
+ html_options(view_context, locals)
429
+ )
430
+ end
431
+ end
432
+
433
+ FILTER_ATTRIBUTES = {
434
+ payout_currency: CurrencyFilter.with_options(
435
+ label: "Currency",
436
+ collection: [["JPY", "JPY"], ["USD", "USD"]]
437
+ )
438
+ }.freeze
439
+ ```
440
+
303
441
  ### Number input helper
304
442
 
305
443
  Admin/Administrate 画面では、`number_field` / `number_field_tag` を
@@ -321,6 +459,7 @@ Admin/Administrate 画面では、`number_field` / `number_field_tag` を
321
459
  //= require yummy_guide_administrate/clipboards
322
460
  //= require yummy_guide_administrate/datetime_input
323
461
  //= require yummy_guide_administrate/fixed_submit_actions
462
+ //= require yummy_guide_administrate/filter_controls
324
463
  //= require yummy_guide_administrate/filter_form
325
464
  //= require yummy_guide_administrate/sticky_left_columns
326
465
  //= require yummy_guide_administrate/sticky_table_headers
@@ -0,0 +1,30 @@
1
+ (function() {
2
+ function initialize(root) {
3
+ var scope = root || document;
4
+ scope.querySelectorAll('[data-admin-filter-form="true"]').forEach(function(formEl) {
5
+ if (formEl.dataset.adminFilterControlsInitialized === 'true') return;
6
+
7
+ formEl.dataset.adminFilterControlsInitialized = 'true';
8
+
9
+ formEl.addEventListener('submit', function(event) {
10
+ if (formEl.dataset.submitMode !== 'event') return;
11
+
12
+ event.preventDefault();
13
+ document.dispatchEvent(new CustomEvent('yummy-guide:administrate-filter:submit', {
14
+ detail: {
15
+ form: formEl,
16
+ formData: new FormData(formEl)
17
+ }
18
+ }));
19
+ });
20
+ });
21
+ }
22
+
23
+ if (document.readyState === 'loading') {
24
+ document.addEventListener('DOMContentLoaded', function() { initialize(document); }, { once: true });
25
+ } else {
26
+ initialize(document);
27
+ }
28
+
29
+ document.addEventListener('turbo:load', function() { initialize(document); });
30
+ })();
@@ -43,6 +43,12 @@
43
43
  });
44
44
  });
45
45
 
46
+ formEl.querySelectorAll('[data-behavior="filter-field-clear"]').forEach(function(buttonEl) {
47
+ buttonEl.addEventListener("click", function(event) {
48
+ clearFilterField(formEl, event);
49
+ });
50
+ });
51
+
46
52
  formEl.querySelectorAll('[data-behavior="checkbox-group-select-all"]').forEach(function(buttonEl) {
47
53
  buttonEl.addEventListener("click", function() {
48
54
  setCheckboxGroupState(formEl, buttonEl.dataset.target, true);
@@ -58,6 +64,19 @@
58
64
  syncDatetimeFilterFields(formEl);
59
65
  }
60
66
 
67
+ function clearFilterField(formEl, event) {
68
+ event.preventDefault();
69
+
70
+ var rowEl = event.currentTarget.closest("tr");
71
+ if (!rowEl) return;
72
+
73
+ clearTextControls(rowEl);
74
+ clearChoiceControls(rowEl);
75
+ clearSelectControls(rowEl);
76
+ clearDatetimeFilters(rowEl);
77
+ syncDatetimeFilterFields(formEl);
78
+ }
79
+
61
80
  function clearFormFields(formEl) {
62
81
  formEl.querySelectorAll("input, select, textarea").forEach(function(fieldEl) {
63
82
  if (fieldEl.disabled || fieldEl.type === "hidden" || fieldEl.type === "submit") {
@@ -86,6 +105,57 @@
86
105
  syncDatetimeFilterFields(formEl);
87
106
  }
88
107
 
108
+ function clearTextControls(rowEl) {
109
+ rowEl.querySelectorAll("input, textarea").forEach(function(inputEl) {
110
+ var inputType = (inputEl.getAttribute("type") || "").toLowerCase();
111
+
112
+ if (inputType === "checkbox" || inputType === "radio") return;
113
+ if (inputType === "hidden" && inputEl.dataset.datetimePart !== "combined") return;
114
+
115
+ inputEl.value = "";
116
+ inputEl.setAttribute("value", "");
117
+ });
118
+ }
119
+
120
+ function clearChoiceControls(rowEl) {
121
+ rowEl.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(function(inputEl) {
122
+ inputEl.checked = false;
123
+ });
124
+ }
125
+
126
+ function clearSelectControls(rowEl) {
127
+ rowEl.querySelectorAll("select").forEach(function(selectEl) {
128
+ if (selectEl.querySelector('option[value=""]')) {
129
+ selectEl.value = "";
130
+ } else if (selectEl.querySelector('option[value="all"]')) {
131
+ selectEl.value = "all";
132
+ } else {
133
+ selectEl.selectedIndex = -1;
134
+ }
135
+ });
136
+ }
137
+
138
+ function clearDatetimeFilters(rowEl) {
139
+ rowEl.querySelectorAll("[data-datetime-filter]").forEach(function(groupEl) {
140
+ var combinedEl = groupEl.querySelector('[data-datetime-part="combined"]');
141
+ var dateEl = groupEl.querySelector('[data-datetime-part="date"]');
142
+
143
+ if (combinedEl) {
144
+ combinedEl.value = "";
145
+ combinedEl.setAttribute("value", "");
146
+ }
147
+
148
+ if (dateEl) {
149
+ dateEl.value = "";
150
+ dateEl.setAttribute("value", "");
151
+ }
152
+
153
+ clearDatetimeTimeParts(groupEl);
154
+ syncDatetimeTimeDisabledState(groupEl);
155
+ syncBlankMinuteOptionState(groupEl);
156
+ });
157
+ }
158
+
89
159
  function setCheckboxGroupState(formEl, groupName, checked) {
90
160
  if (!groupName) return;
91
161
 
@@ -216,4 +286,3 @@
216
286
 
217
287
  document.addEventListener("turbo:load", initializeFromDocument);
218
288
  })();
219
-
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YummyGuide
4
+ module Administrate
5
+ module FilterControlsHelper
6
+ def admin_filter_controls(
7
+ dashboard: nil,
8
+ page: nil,
9
+ path: nil,
10
+ clear_path: nil,
11
+ search_options: {},
12
+ form: :search_options,
13
+ method: :get,
14
+ hidden_fields: {},
15
+ root_hidden_fields: {},
16
+ filter_locals: {},
17
+ extra_actions: [],
18
+ button_label: "Filter",
19
+ title: "Filter Options",
20
+ submit_label: "Filter"
21
+ )
22
+ dashboard ||= admin_filter_dashboard_from_page(page)
23
+ scope = form.to_s
24
+ current_values = admin_filter_current_values(search_options)
25
+ base_locals = filter_locals.merge(
26
+ dashboard: dashboard,
27
+ page: page,
28
+ search_options: search_options,
29
+ current_values: current_values,
30
+ form_scope: scope
31
+ )
32
+ fields = admin_filter_visible_fields(dashboard, base_locals)
33
+ return if fields.blank?
34
+
35
+ path ||= admin_filter_dashboard_setting(dashboard, :filter_path, :FILTER_PATH, base_locals)
36
+ raise ArgumentError, "admin_filter_controls requires path or dashboard FILTER_PATH" if path.blank?
37
+
38
+ clear_path ||= admin_filter_dashboard_setting(dashboard, :filter_clear_path, :FILTER_CLEAR_PATH, base_locals) || path
39
+ locals = filter_locals.merge(
40
+ dashboard: dashboard,
41
+ page: page,
42
+ path: path,
43
+ clear_path: clear_path,
44
+ search_options: search_options,
45
+ current_values: current_values,
46
+ form_scope: scope
47
+ )
48
+
49
+ safe_join([
50
+ content_tag(:div, "", class: "modal_overlay"),
51
+ content_tag(:div, id: "reserv-filter-options") do
52
+ safe_join([
53
+ link_to(button_label, "javascript:void(0)", class: "button"),
54
+ admin_filter_form(
55
+ fields: fields,
56
+ scope: scope,
57
+ path: path,
58
+ clear_path: clear_path,
59
+ method: method,
60
+ current_values: current_values,
61
+ hidden_fields: hidden_fields,
62
+ root_hidden_fields: root_hidden_fields,
63
+ locals: locals,
64
+ extra_actions: extra_actions,
65
+ title: title,
66
+ submit_label: submit_label
67
+ )
68
+ ])
69
+ end
70
+ ])
71
+ end
72
+
73
+ def admin_filter_dashboard_from_page(page)
74
+ return page.dashboard if page.respond_to?(:dashboard)
75
+ return unless page.respond_to?(:instance_variable_defined?) && page.instance_variable_defined?(:@dashboard)
76
+
77
+ page.instance_variable_get(:@dashboard)
78
+ end
79
+
80
+ def admin_filter_dashboard_setting(dashboard, method_name, constant_name, locals)
81
+ return if dashboard.blank?
82
+
83
+ value =
84
+ if dashboard.respond_to?(method_name)
85
+ dashboard.public_send(method_name)
86
+ else
87
+ admin_filter_dashboard_constant(dashboard, constant_name)
88
+ end
89
+
90
+ admin_filter_evaluate_value(value, locals)
91
+ end
92
+
93
+ def admin_filter_dashboard_constant(dashboard, constant_name)
94
+ target = dashboard.is_a?(Class) ? dashboard : dashboard.class
95
+ target.const_get(constant_name, false) if target.const_defined?(constant_name, false)
96
+ end
97
+
98
+ def admin_filter_visible_fields(dashboard, locals)
99
+ return [] if dashboard.blank?
100
+
101
+ YummyGuide::Administrate::Filters::Resolver
102
+ .attributes_for(dashboard)
103
+ .values
104
+ .select { |field| field.visible?(self, locals) }
105
+ end
106
+
107
+ def admin_filter_form(fields:, scope:, path:, clear_path:, method:, current_values:, hidden_fields:, root_hidden_fields:, locals:, extra_actions:, title:, submit_label:)
108
+ form_with(
109
+ url: path,
110
+ scope: scope,
111
+ method: method,
112
+ html: {
113
+ class: "filter-form",
114
+ data: {
115
+ yummy_guide_administrate_filter_form: true,
116
+ admin_filter_form: true
117
+ }
118
+ }
119
+ ) do |f|
120
+ safe_join([
121
+ admin_filter_hidden_fields(scope, hidden_fields, root_hidden_fields),
122
+ content_tag(:h2, title, class: "filter-form__title"),
123
+ content_tag(:div, class: "filter-form__body") do
124
+ content_tag(:table, class: "filter_table") do
125
+ safe_join(fields.map { |field| field.row(self, f, scope, current_values, locals) })
126
+ end
127
+ end,
128
+ content_tag(:div, class: "filter-form__actions") do
129
+ safe_join([
130
+ link_to("Clear", clear_path, class: "button button--outline-primary"),
131
+ Array(extra_actions),
132
+ f.submit(submit_label, class: "submit_filter")
133
+ ].flatten)
134
+ end
135
+ ])
136
+ end
137
+ end
138
+
139
+ def admin_filter_hidden_fields(scope, hidden_fields, root_hidden_fields)
140
+ root_tags = admin_filter_evaluated_hash(root_hidden_fields).map do |key, value|
141
+ hidden_field_tag(key, value)
142
+ end
143
+
144
+ scoped_tags = admin_filter_evaluated_hash(hidden_fields).map do |key, value|
145
+ hidden_field_tag("#{scope}[#{key}]", value)
146
+ end
147
+
148
+ safe_join(root_tags + scoped_tags)
149
+ end
150
+
151
+ def admin_filter_evaluated_hash(values)
152
+ values = admin_filter_evaluate_value(values, {})
153
+ (values || {}).to_h
154
+ end
155
+
156
+ def admin_filter_evaluate_value(value, locals)
157
+ return value unless value.respond_to?(:call)
158
+
159
+ case value.arity
160
+ when 0
161
+ value.call
162
+ when 1
163
+ value.call(self)
164
+ else
165
+ value.call(self, locals)
166
+ end
167
+ end
168
+
169
+ def admin_filter_current_values(raw_values)
170
+ values =
171
+ if raw_values.respond_to?(:to_unsafe_h)
172
+ raw_values.to_unsafe_h
173
+ elsif raw_values.respond_to?(:to_h)
174
+ raw_values.to_h
175
+ else
176
+ raw_values || {}
177
+ end
178
+
179
+ values.deep_stringify_keys
180
+ end
181
+ end
182
+ end
183
+ end
@@ -11,17 +11,20 @@ module YummyGuide
11
11
  yummy_guide_administrate/clipboards.js
12
12
  yummy_guide_administrate/datetime_input.js
13
13
  yummy_guide_administrate/fixed_submit_actions.js
14
+ yummy_guide_administrate/filter_controls.js
14
15
  yummy_guide_administrate/filter_form.js
15
16
  yummy_guide_administrate/sticky_left_columns.js
16
17
  yummy_guide_administrate/sticky_table_headers.js
17
18
  ]
18
19
  end
19
20
 
20
- initializer "yummy_guide.administrate.number_input_helper" do |app|
21
+ initializer "yummy_guide.administrate.helpers" do |app|
21
22
  app.config.to_prepare do
22
23
  next unless defined?(::Administrate::ApplicationController)
23
24
 
24
25
  ::Administrate::ApplicationController.helper YummyGuide::Administrate::NumberInputHelper
26
+ ::Administrate::ApplicationController.helper YummyGuide::Administrate::FilterFormHelper
27
+ ::Administrate::ApplicationController.helper YummyGuide::Administrate::FilterControlsHelper
25
28
  end
26
29
  end
27
30
  end
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YummyGuide
4
+ module Administrate
5
+ module Filters
6
+ class Base
7
+ attr_reader :name, :options
8
+
9
+ def self.with_options(options = {})
10
+ new(options)
11
+ end
12
+
13
+ def initialize(options = {})
14
+ @options = options.symbolize_keys
15
+ end
16
+
17
+ def with_name(name)
18
+ copy = dup
19
+ copy.instance_variable_set(:@name, name.to_sym)
20
+ copy
21
+ end
22
+
23
+ def visible?(view_context, locals)
24
+ condition = options.fetch(:if, true)
25
+ evaluate_option(condition, view_context, locals) != false
26
+ end
27
+
28
+ def row(view_context, form, scope, current_values, locals)
29
+ view_context.content_tag(:tr) do
30
+ view_context.safe_join([
31
+ label_cell(view_context, form, locals),
32
+ input_cell(view_context, form, scope, current_values, locals),
33
+ clear_cell(view_context)
34
+ ])
35
+ end
36
+ end
37
+
38
+ def label_text(view_context, locals)
39
+ evaluated_label = evaluate_option(options[:label], view_context, locals)
40
+ return evaluated_label if evaluated_label.present?
41
+
42
+ name.to_s.humanize
43
+ end
44
+
45
+ protected
46
+
47
+ def label_cell(view_context, form, locals)
48
+ view_context.content_tag(:td, form.label(name, label_text(view_context, locals)))
49
+ end
50
+
51
+ def input_cell(view_context, form, _scope, current_values, locals)
52
+ view_context.content_tag(:td, input(view_context, form, current_values, locals))
53
+ end
54
+
55
+ def clear_cell(view_context)
56
+ return view_context.filter_field_clear_cell if view_context.respond_to?(:filter_field_clear_cell)
57
+
58
+ view_context.content_tag(:td, "", class: "filter-table__clear")
59
+ end
60
+
61
+ def current_value(current_values)
62
+ value = current_values[name.to_s]
63
+ value.presence || options[:default]
64
+ end
65
+
66
+ def html_options(view_context, locals)
67
+ options.slice(:placeholder, :inputmode, :pattern, :class, :id, :autocomplete).transform_values do |value|
68
+ evaluate_option(value, view_context, locals)
69
+ end.compact
70
+ end
71
+
72
+ def evaluate_option(value, view_context, locals)
73
+ return value unless value.respond_to?(:call)
74
+
75
+ case value.arity
76
+ when 0
77
+ value.call
78
+ when 1
79
+ value.call(view_context)
80
+ else
81
+ value.call(view_context, locals)
82
+ end
83
+ end
84
+
85
+ def normalize_options(view_context, locals)
86
+ raw_options = evaluate_option(options[:collection] || options[:options], view_context, locals)
87
+
88
+ Array(raw_options).map do |option|
89
+ if option.is_a?(Array)
90
+ option
91
+ else
92
+ [option, option]
93
+ end
94
+ end
95
+ end
96
+
97
+ def input(_view_context, _form, _current_values, _locals)
98
+ raise NotImplementedError
99
+ end
100
+ end
101
+
102
+ class Text < Base
103
+ private
104
+
105
+ def input(view_context, form, current_values, locals)
106
+ form.text_field(name, html_options(view_context, locals).merge(value: current_value(current_values)))
107
+ end
108
+ end
109
+
110
+ class Select < Base
111
+ private
112
+
113
+ def input(view_context, form, current_values, locals)
114
+ selected = current_value(current_values)
115
+ form.select(
116
+ name,
117
+ view_context.options_for_select(normalize_options(view_context, locals), selected),
118
+ evaluate_option(options[:select_options] || {}, view_context, locals),
119
+ html_options(view_context, locals)
120
+ )
121
+ end
122
+ end
123
+
124
+ class Checkbox < Base
125
+ private
126
+
127
+ def input(view_context, form, current_values, locals)
128
+ checked_value = options.fetch(:checked_value, "true")
129
+ unchecked_value = options.fetch(:unchecked_value, "false")
130
+ checked = ActiveModel::Type::Boolean.new.cast(current_value(current_values))
131
+ form.check_box(name, html_options(view_context, locals).merge(checked: checked), checked_value, unchecked_value)
132
+ end
133
+ end
134
+
135
+ class RadioGroup < Base
136
+ protected
137
+
138
+ def input_cell(view_context, _form, scope, current_values, locals)
139
+ selected = current_value(current_values).to_s
140
+ controls = normalize_options(view_context, locals).map do |label, value|
141
+ value_string = value.to_s
142
+ id = "#{scope}_#{name}_#{value_string.parameterize(separator: "_")}"
143
+ view_context.content_tag(:label, style: "display: flex; align-items: center; gap: 6px;") do
144
+ view_context.safe_join([
145
+ view_context.radio_button_tag("#{scope}[#{name}]", value, selected == value_string, id: id),
146
+ view_context.content_tag(:span, label)
147
+ ])
148
+ end
149
+ end
150
+
151
+ view_context.content_tag(:td, view_context.safe_join(controls))
152
+ end
153
+ end
154
+
155
+ class CheckboxGroup < Base
156
+ protected
157
+
158
+ def input_cell(view_context, _form, scope, current_values, locals)
159
+ selected_values = Array(current_values[name.to_s]).map(&:to_s).reject(&:blank?)
160
+ group_name = (options[:group] || name).to_s.dasherize
161
+ controls = normalize_options(view_context, locals).map do |label, value|
162
+ value_string = value.to_s
163
+ id = "#{scope}_#{name}_#{value_string.parameterize(separator: "_")}"
164
+ view_context.content_tag(:label, style: "display: flex; align-items: center; gap: 6px;") do
165
+ view_context.safe_join([
166
+ view_context.check_box_tag(
167
+ "#{scope}[#{name}][]",
168
+ value,
169
+ selected_values.include?(value_string),
170
+ id: id,
171
+ data: { checkbox_group_item: group_name }
172
+ ),
173
+ view_context.content_tag(:span, label)
174
+ ])
175
+ end
176
+ end
177
+
178
+ view_context.content_tag(:td) do
179
+ view_context.content_tag(:div, data: { checkbox_group: group_name }) do
180
+ view_context.content_tag(:div, view_context.safe_join(controls), class: "filter-checkbox-group__options")
181
+ end
182
+ end
183
+ end
184
+
185
+ def clear_cell(view_context)
186
+ group_name = (options[:group] || name).to_s.dasherize
187
+ return view_context.checkbox_group_action_cell(target: group_name) if view_context.respond_to?(:checkbox_group_action_cell)
188
+
189
+ super
190
+ end
191
+ end
192
+
193
+ class DatetimeRange < Base
194
+ def row(view_context, form, scope, current_values, locals)
195
+ view_context.content_tag(:tr) do
196
+ view_context.safe_join([
197
+ label_cell(view_context, form, locals),
198
+ view_context.content_tag(:td, range_inputs(view_context, scope, current_values, locals)),
199
+ clear_cell(view_context)
200
+ ])
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def range_inputs(view_context, scope, current_values, locals)
207
+ from_name = (options[:from] || :"start_#{name}").to_sym
208
+ to_name = (options[:to] || :"end_#{name}").to_sym
209
+ css_class = evaluate_option(options[:css_class] || "#{scope}_#{name}", view_context, locals)
210
+
211
+ view_context.safe_join([
212
+ view_context.render(
213
+ "yummy_guide/administrate/filter_forms/datetime_field",
214
+ form_scope: scope,
215
+ field_name: from_name,
216
+ current_value: current_values[from_name.to_s],
217
+ css_class: css_class,
218
+ end_target: to_name
219
+ ),
220
+ view_context.content_tag(:p, "〜", class: "filter-datetime-range-separator"),
221
+ view_context.render(
222
+ "yummy_guide/administrate/filter_forms/datetime_field",
223
+ form_scope: scope,
224
+ field_name: to_name,
225
+ current_value: current_values[to_name.to_s],
226
+ css_class: css_class,
227
+ end_of_day: true
228
+ )
229
+ ])
230
+ end
231
+ end
232
+
233
+ class DateRange < Base
234
+ def row(view_context, form, _scope, current_values, locals)
235
+ from_name = (options[:from] || :"start_#{name}").to_sym
236
+ to_name = (options[:to] || :"end_#{name}").to_sym
237
+
238
+ view_context.content_tag(:tr) do
239
+ view_context.safe_join([
240
+ label_cell(view_context, form, locals),
241
+ view_context.content_tag(:td) do
242
+ view_context.safe_join([
243
+ form.date_field(from_name, value: current_values[from_name.to_s].presence || evaluate_option(options[:from_default], view_context, locals)),
244
+ view_context.content_tag(:span, "〜"),
245
+ form.date_field(to_name, value: current_values[to_name.to_s].presence || evaluate_option(options[:to_default], view_context, locals))
246
+ ])
247
+ end,
248
+ clear_cell(view_context)
249
+ ])
250
+ end
251
+ end
252
+ end
253
+
254
+ class DatetimeLocalRange < Base
255
+ def row(view_context, form, _scope, current_values, locals)
256
+ from_name = (options[:from] || :"start_#{name}").to_sym
257
+ to_name = (options[:to] || :"end_#{name}").to_sym
258
+
259
+ view_context.content_tag(:tr) do
260
+ view_context.safe_join([
261
+ label_cell(view_context, form, locals),
262
+ view_context.content_tag(:td) do
263
+ view_context.safe_join([
264
+ form.datetime_local_field(from_name, value: current_values[from_name.to_s].presence || evaluate_option(options[:from_default], view_context, locals)),
265
+ view_context.content_tag(:p, "〜", style: "text-align: center; margin: 0;"),
266
+ form.datetime_local_field(to_name, value: current_values[to_name.to_s].presence || evaluate_option(options[:to_default], view_context, locals))
267
+ ])
268
+ end,
269
+ clear_cell(view_context)
270
+ ])
271
+ end
272
+ end
273
+ end
274
+
275
+ class Custom < Base
276
+ def row(view_context, form, scope, current_values, locals)
277
+ view_context.render(
278
+ options.fetch(:partial),
279
+ form: form,
280
+ form_scope: scope,
281
+ field: self,
282
+ current_values: current_values,
283
+ filter_locals: locals
284
+ )
285
+ end
286
+ end
287
+
288
+ module Resolver
289
+ module_function
290
+
291
+ def attributes_for(dashboard)
292
+ source = dashboard.respond_to?(:filter_attributes) ? dashboard.filter_attributes : constant_value(dashboard, :FILTER_ATTRIBUTES)
293
+ normalize_attributes(source || {})
294
+ end
295
+
296
+ def normalize_attributes(attributes)
297
+ attributes.to_h.map do |name, field|
298
+ [name.to_sym, normalize_field(name, field)]
299
+ end.to_h
300
+ end
301
+
302
+ def normalize_field(name, field)
303
+ if field.respond_to?(:with_name)
304
+ field.with_name(name)
305
+ elsif field.is_a?(Class) && field < Base
306
+ field.with_options.with_name(name)
307
+ else
308
+ raise ArgumentError, "Unsupported filter field for #{name}: #{field.inspect}"
309
+ end
310
+ end
311
+
312
+ def constant_value(dashboard, name)
313
+ target = dashboard.is_a?(Class) ? dashboard : dashboard.class
314
+ target.const_get(name, false) if target.const_defined?(name, false)
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module YummyGuide
4
4
  module Administrate
5
- VERSION = "0.5.4"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
@@ -4,7 +4,10 @@ require "rails"
4
4
  require "administrate"
5
5
 
6
6
  require_relative "../../app/helpers/yummy_guide/administrate/number_input_helper"
7
+ require_relative "../../app/helpers/yummy_guide/administrate/filter_form_helper"
8
+ require_relative "../../app/helpers/yummy_guide/administrate/filter_controls_helper"
7
9
  require_relative "administrate/version"
10
+ require_relative "administrate/filters"
8
11
  require_relative "administrate/engine"
9
12
 
10
13
  module YummyGuide
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+
6
+ RSpec.describe YummyGuide::Administrate::FilterControlsHelper do
7
+ subject(:helper_host) do
8
+ Class.new do
9
+ include ActionView::Context
10
+ include ActionView::Helpers::FormHelper
11
+ include ActionView::Helpers::FormOptionsHelper
12
+ include ActionView::Helpers::FormTagHelper
13
+ include ActionView::Helpers::OutputSafetyHelper
14
+ include ActionView::Helpers::TagHelper
15
+ include ActionView::Helpers::UrlHelper
16
+ include YummyGuide::Administrate::FilterControlsHelper
17
+ end.new
18
+ end
19
+
20
+ def fragment(html)
21
+ Nokogiri::HTML.fragment(html)
22
+ end
23
+
24
+ describe "#admin_filter_controls" do
25
+ # dashboard の FILTER_ATTRIBUTES から Filter ボタンとフォーム項目を描画することを確認する
26
+ it "renders controls from dashboard filter attributes" do
27
+ dashboard = Class.new
28
+ dashboard.const_set(
29
+ :FILTER_ATTRIBUTES,
30
+ {
31
+ keyword: YummyGuide::Administrate::Filters::Text.with_options(label: "Keyword"),
32
+ status: YummyGuide::Administrate::Filters::Select.with_options(
33
+ label: "Status",
34
+ collection: [["Open", "open"], ["Closed", "closed"]]
35
+ )
36
+ }.freeze
37
+ )
38
+
39
+ html = helper_host.admin_filter_controls(
40
+ dashboard: dashboard,
41
+ path: "/admin/resources",
42
+ search_options: { keyword: "tokyo", status: "closed" }
43
+ )
44
+ document = fragment(html)
45
+
46
+ expect(document.at_css("#reserv-filter-options > a.button").text).to eq("Filter")
47
+ expect(document.at_css("form.filter-form")["action"]).to eq("/admin/resources")
48
+ expect(document.at_css('input[name="search_options[keyword]"]')["value"]).to eq("tokyo")
49
+ expect(document.at_css('select[name="search_options[status]"] option[selected]')["value"]).to eq("closed")
50
+ end
51
+
52
+ # dashboard に定義した FILTER_PATH をフィルター送信先として利用できることを確認する
53
+ it "uses dashboard filter path when no explicit path is passed" do
54
+ dashboard = Class.new
55
+ dashboard.const_set(
56
+ :FILTER_ATTRIBUTES,
57
+ {
58
+ keyword: YummyGuide::Administrate::Filters::Text.with_options(label: "Keyword")
59
+ }.freeze
60
+ )
61
+ dashboard.const_set(:FILTER_PATH, ->(view, _locals) { view.filter_path })
62
+
63
+ helper_host.define_singleton_method(:filter_path) { "/admin/dashboard-filters" }
64
+
65
+ html = helper_host.admin_filter_controls(dashboard: dashboard)
66
+ document = fragment(html)
67
+
68
+ expect(document.at_css("form.filter-form")["action"]).to eq("/admin/dashboard-filters")
69
+ end
70
+
71
+ # dashboard にフィルター定義が存在しない場合は Filter ボタンを描画しないことを確認する
72
+ it "does not render controls when dashboard has no filter attributes" do
73
+ expect(helper_host.admin_filter_controls(dashboard: Class.new, path: "/admin/resources")).to be_nil
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe YummyGuide::Administrate::Filters do
6
+ describe YummyGuide::Administrate::Filters::Resolver do
7
+ # dashboard に定義した Field クラス型フィルターへ属性名が付与されることを確認する
8
+ it "normalizes dashboard filter attributes with field names" do
9
+ dashboard = Class.new
10
+ dashboard.const_set(
11
+ :FILTER_ATTRIBUTES,
12
+ {
13
+ keyword: YummyGuide::Administrate::Filters::Text.with_options(label: "Keyword")
14
+ }.freeze
15
+ )
16
+
17
+ fields = described_class.attributes_for(dashboard)
18
+
19
+ expect(fields[:keyword]).to be_a(YummyGuide::Administrate::Filters::Text)
20
+ expect(fields[:keyword].name).to eq(:keyword)
21
+ end
22
+ end
23
+
24
+ describe "#visible?" do
25
+ # view context に依存する条件でフィルター表示を切り替えられることを確認する
26
+ it "evaluates visibility conditions with the view context" do
27
+ view_context = double("view_context", owner?: true)
28
+ field = YummyGuide::Administrate::Filters::Text
29
+ .with_options(if: ->(view, _locals) { !view.owner? })
30
+ .with_name(:owner_name)
31
+
32
+ expect(field.visible?(view_context, {})).to be(false)
33
+ end
34
+ end
35
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yummy-guide-generic-administrate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - akatsuki-kk
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-18 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: administrate
@@ -92,6 +92,7 @@ files:
92
92
  - app/assets/images/yummy_guide_administrate/icon-copy.svg
93
93
  - app/assets/javascripts/yummy_guide_administrate/clipboards.js
94
94
  - app/assets/javascripts/yummy_guide_administrate/datetime_input.js
95
+ - app/assets/javascripts/yummy_guide_administrate/filter_controls.js
95
96
  - app/assets/javascripts/yummy_guide_administrate/filter_form.js
96
97
  - app/assets/javascripts/yummy_guide_administrate/fixed_submit_actions.js
97
98
  - app/assets/javascripts/yummy_guide_administrate/sticky_left_columns.js
@@ -108,6 +109,7 @@ files:
108
109
  - app/fields/yummy_guide/administrate/fields/version_whodunnit_field.rb
109
110
  - app/helpers/yummy_guide/administrate/collection_helper.rb
110
111
  - app/helpers/yummy_guide/administrate/datetime_input_helper.rb
112
+ - app/helpers/yummy_guide/administrate/filter_controls_helper.rb
111
113
  - app/helpers/yummy_guide/administrate/filter_form_helper.rb
112
114
  - app/helpers/yummy_guide/administrate/number_input_helper.rb
113
115
  - app/views/fields/number/_form.html.erb
@@ -129,6 +131,7 @@ files:
129
131
  - lib/yummy/guide/generic/administrate.rb
130
132
  - lib/yummy_guide/administrate.rb
131
133
  - lib/yummy_guide/administrate/engine.rb
134
+ - lib/yummy_guide/administrate/filters.rb
132
135
  - lib/yummy_guide/administrate/version.rb
133
136
  - spec/spec_helper.rb
134
137
  - spec/views/fields/number/form_spec.rb
@@ -140,7 +143,9 @@ files:
140
143
  - spec/yummy_guide/administrate/fields/json_pretty_field_spec.rb
141
144
  - spec/yummy_guide/administrate/fields/version_item_field_spec.rb
142
145
  - spec/yummy_guide/administrate/fields/version_whodunnit_field_spec.rb
146
+ - spec/yummy_guide/administrate/filter_controls_helper_spec.rb
143
147
  - spec/yummy_guide/administrate/filter_form_helper_spec.rb
148
+ - spec/yummy_guide/administrate/filters_spec.rb
144
149
  - spec/yummy_guide/administrate/number_input_helper_spec.rb
145
150
  - yummy-guide-generic-administrate.gemspec
146
151
  homepage: https://github.com/yummy-guide/generic-administrate