yummy-guide-generic-administrate 0.5.3 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a6c5ac8336b23dfbac722bbf45ff6b9e2da1f92df3a9735d141d6f87cdf4785
4
- data.tar.gz: a9e60712757ed858f26e6a14ea3a012898f4b52a146bdc777f6fd1691a82d244
3
+ metadata.gz: 8906e8c09072b22eebdaeaa49b0fb57a395aa30693bf6f9986ebc3c0687e254d
4
+ data.tar.gz: 802a739018ffc6e269a688a459464e17e213c6d6ee195d7aacd6e396aa74f97a
5
5
  SHA512:
6
- metadata.gz: d859073ea2ca3675c5ca819b451c3c0236407bbb05071ed2291795d811332e53b7daaf15beb715d27e238c872c6001fd3fbcc502d17943ebdcd21da456a39901
7
- data.tar.gz: 72af6d3873e0bbf648c5f038105ebfa1b1130876a7831750e7b7594d8d86ac1e39c82e3e62b5c75f3fbd60972d186d77b1d3fcad28ce33501aeb85b6bbf5699e
6
+ metadata.gz: b37b6a8dfbe765ab42af40cc6c2db86a7f568c049af2a6c3a47d3ef5871bacb2351896b15b9f1368ad446a97f6d9e1a3eab44cbd1d045b1ab2efd68a2fbfbb31
7
+ data.tar.gz: 84255b48f37a7b0926e74228cd24b1ff0d5080f7c7dedc89b7b7ecedf86eff3a871299bbd002882eefe9ad3f0b4800ea3b706eae7ddd39806ca11d2317fecdf0
data/README.md CHANGED
@@ -41,8 +41,12 @@ 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
48
+ - `YummyGuide::Administrate::NumberInputHelper`
49
+ - 管理画面フォーム用の number input text 化 helper
46
50
  - 共通 partial / assets
47
51
  - collection partial
48
52
  - fixed table header partial
@@ -50,6 +54,7 @@ bundle install
50
54
  - `clipboards.js`
51
55
  - `datetime_input.js`
52
56
  - `fixed_submit_actions.js`
57
+ - `filter_controls.js`
53
58
  - `filter_form.js`
54
59
  - `sticky_left_columns.js`
55
60
  - `sticky_table_headers.js`
@@ -101,10 +106,16 @@ class Admin::ApplicationController < Administrate::ApplicationController
101
106
 
102
107
  helper YummyGuide::Administrate::CollectionHelper
103
108
  helper YummyGuide::Administrate::DatetimeInputHelper
109
+ helper YummyGuide::Administrate::FilterControlsHelper
104
110
  helper YummyGuide::Administrate::FilterFormHelper
111
+ helper YummyGuide::Administrate::NumberInputHelper
105
112
  end
106
113
  ```
107
114
 
115
+ `NumberInputHelper` は `Administrate::ApplicationController` の view helper として
116
+ 自動適用されます。`Administrate::ApplicationController` を継承しない独自 admin
117
+ controller で同じ挙動が必要な場合だけ、上記のように明示的に読み込んでください。
118
+
108
119
  ### Collection partial
109
120
 
110
121
  ホストアプリ側の `app/views/administrate/application/_collection.html.erb` から、
@@ -293,6 +304,149 @@ def search_term
293
304
  end
294
305
  ```
295
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 型は `YummyGuide::Administrate::Filters::Base` を継承して作ります。
413
+ 単一 input なら `input` を実装し、行全体を制御したい場合は `row` または `input_cell` を上書きします。
414
+
415
+ ```ruby
416
+ class CurrencyFilter < YummyGuide::Administrate::Filters::Base
417
+ private
418
+
419
+ def input(view_context, form, current_values, locals)
420
+ form.select(
421
+ name,
422
+ view_context.options_for_select(options.fetch(:collection), current_value(current_values)),
423
+ { include_blank: true },
424
+ html_options(view_context, locals)
425
+ )
426
+ end
427
+ end
428
+
429
+ FILTER_ATTRIBUTES = {
430
+ payout_currency: CurrencyFilter.with_options(
431
+ label: "Currency",
432
+ collection: [["JPY", "JPY"], ["USD", "USD"]]
433
+ )
434
+ }.freeze
435
+ ```
436
+
437
+ ### Number input helper
438
+
439
+ Admin/Administrate 画面では、`number_field` / `number_field_tag` を
440
+ `type="text"` かつ `inputmode="decimal"` の input として描画します。これにより、
441
+ ブラウザ標準の number spinner や mouse wheel による意図しない数値変更を避けます。
442
+
443
+ `class`, `id`, `name`, `value`, `data`, `required`, `disabled`, `readonly`,
444
+ `placeholder` などの通常 option は維持されます。`min`, `max`, `step`, `in`,
445
+ `within` は `type="number"` 前提のブラウザ制御なので出力しません。
446
+
447
+ `range_field` / `range_field_tag` は対象外で、従来どおり `type="range"` として描画
448
+ されます。raw HTML の `<input type="number">` は helper を通らないため対象外です。
449
+
296
450
  ### Asset の読み込み
297
451
 
298
452
  この engine の asset はホストアプリ側で明示的に読み込んでください。
@@ -301,6 +455,7 @@ end
301
455
  //= require yummy_guide_administrate/clipboards
302
456
  //= require yummy_guide_administrate/datetime_input
303
457
  //= require yummy_guide_administrate/fixed_submit_actions
458
+ //= require yummy_guide_administrate/filter_controls
304
459
  //= require yummy_guide_administrate/filter_form
305
460
  //= require yummy_guide_administrate/sticky_left_columns
306
461
  //= 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
+ })();
@@ -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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YummyGuide
4
+ module Administrate
5
+ module NumberInputHelper
6
+ NUMBER_INPUT_CONTROL_OPTIONS = %i[type min max step in within].freeze
7
+
8
+ def number_field(object_name, method, options = {})
9
+ text_field(object_name, method, yummy_guide_administrate_number_input_options(options))
10
+ end
11
+
12
+ def number_field_tag(name, value = nil, options = {})
13
+ return super if yummy_guide_administrate_range_input?(options)
14
+
15
+ text_field_tag(name, value, yummy_guide_administrate_number_input_options(options))
16
+ end
17
+
18
+ private
19
+
20
+ def yummy_guide_administrate_range_input?(options)
21
+ options[:type].to_s == "range" || options["type"].to_s == "range"
22
+ end
23
+
24
+ def yummy_guide_administrate_number_input_options(options)
25
+ options = options.to_h.deep_dup
26
+
27
+ NUMBER_INPUT_CONTROL_OPTIONS.each do |option|
28
+ options.delete(option)
29
+ options.delete(option.to_s)
30
+ end
31
+
32
+ options[:inputmode] ||= "decimal"
33
+ options
34
+ end
35
+ end
36
+ end
37
+ end
@@ -11,11 +11,22 @@ 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
20
+
21
+ initializer "yummy_guide.administrate.helpers" do |app|
22
+ app.config.to_prepare do
23
+ next unless defined?(::Administrate::ApplicationController)
24
+
25
+ ::Administrate::ApplicationController.helper YummyGuide::Administrate::NumberInputHelper
26
+ ::Administrate::ApplicationController.helper YummyGuide::Administrate::FilterFormHelper
27
+ ::Administrate::ApplicationController.helper YummyGuide::Administrate::FilterControlsHelper
28
+ end
29
+ end
19
30
  end
20
31
  end
21
32
  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.3"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -3,11 +3,14 @@
3
3
  require "rails"
4
4
  require "administrate"
5
5
 
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"
6
9
  require_relative "administrate/version"
10
+ require_relative "administrate/filters"
7
11
  require_relative "administrate/engine"
8
12
 
9
13
  module YummyGuide
10
14
  module Administrate
11
15
  end
12
16
  end
13
-
@@ -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
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+
6
+ RSpec.describe YummyGuide::Administrate::NumberInputHelper do
7
+ subject(:helper_host) do
8
+ Class.new do
9
+ include ActionView::Context
10
+ include ActionView::Helpers::FormHelper
11
+ include ActionView::Helpers::FormTagHelper
12
+ include ActionView::Helpers::OutputSafetyHelper
13
+ include ActionView::Helpers::TagHelper
14
+ include YummyGuide::Administrate::NumberInputHelper
15
+ end.new
16
+ end
17
+
18
+ before do
19
+ stub_const("SpecNumberInputResource", Class.new do
20
+ extend ActiveModel::Naming
21
+ include ActiveModel::Conversion
22
+
23
+ attr_accessor :base_salary
24
+
25
+ def persisted?
26
+ false
27
+ end
28
+ end)
29
+ end
30
+
31
+ let(:resource) do
32
+ SpecNumberInputResource.new.tap do |record|
33
+ record.base_salary = "123.45"
34
+ end
35
+ end
36
+
37
+ def fragment(html)
38
+ Nokogiri::HTML.fragment(html)
39
+ end
40
+
41
+ describe "#number_field" do
42
+ it "renders a decimal-friendly text input without number-only control attributes" do
43
+ html = helper_host.number_field(
44
+ :job_match,
45
+ :base_salary,
46
+ class: "payment-input",
47
+ data: { role: "base-salary" },
48
+ required: true,
49
+ disabled: true,
50
+ readonly: true,
51
+ placeholder: "0.0",
52
+ value: "100.5",
53
+ min: 0,
54
+ max: 999,
55
+ step: "any",
56
+ in: 1..10,
57
+ within: 1..10
58
+ )
59
+ input = fragment(html).at_css("input")
60
+
61
+ expect(input["type"]).to eq("text")
62
+ expect(input["inputmode"]).to eq("decimal")
63
+ expect(input["class"]).to eq("payment-input")
64
+ expect(input["id"]).to eq("job_match_base_salary")
65
+ expect(input["name"]).to eq("job_match[base_salary]")
66
+ expect(input["value"]).to eq("100.5")
67
+ expect(input["data-role"]).to eq("base-salary")
68
+ expect(input["required"]).to eq("required")
69
+ expect(input["disabled"]).to eq("disabled")
70
+ expect(input["readonly"]).to eq("readonly")
71
+ expect(input["placeholder"]).to eq("0.0")
72
+ expect(input["min"]).to be_nil
73
+ expect(input["max"]).to be_nil
74
+ expect(input["step"]).to be_nil
75
+ end
76
+
77
+ it "keeps range fields as range inputs" do
78
+ input = fragment(helper_host.range_field(:job_match, :base_salary, value: "50")).at_css("input")
79
+
80
+ expect(input["type"]).to eq("range")
81
+ expect(input["value"]).to eq("50")
82
+ end
83
+ end
84
+
85
+ describe "#number_field_tag" do
86
+ it "renders a decimal-friendly text input without number-only control attributes" do
87
+ html = helper_host.number_field_tag(
88
+ "job_match[base_salary]",
89
+ "200.5",
90
+ id: "base_salary",
91
+ class: "payment-input",
92
+ data: { role: "base-salary" },
93
+ required: true,
94
+ disabled: true,
95
+ readonly: true,
96
+ placeholder: "0.0",
97
+ min: 0,
98
+ max: 999,
99
+ step: "any",
100
+ in: 1..10,
101
+ within: 1..10
102
+ )
103
+ input = fragment(html).at_css("input")
104
+
105
+ expect(input["type"]).to eq("text")
106
+ expect(input["inputmode"]).to eq("decimal")
107
+ expect(input["id"]).to eq("base_salary")
108
+ expect(input["name"]).to eq("job_match[base_salary]")
109
+ expect(input["value"]).to eq("200.5")
110
+ expect(input["class"]).to eq("payment-input")
111
+ expect(input["data-role"]).to eq("base-salary")
112
+ expect(input["required"]).to eq("required")
113
+ expect(input["disabled"]).to eq("disabled")
114
+ expect(input["readonly"]).to eq("readonly")
115
+ expect(input["placeholder"]).to eq("0.0")
116
+ expect(input["min"]).to be_nil
117
+ expect(input["max"]).to be_nil
118
+ expect(input["step"]).to be_nil
119
+ end
120
+
121
+ it "keeps range field tags as range inputs" do
122
+ input = fragment(helper_host.range_field_tag("base_salary", "50")).at_css("input")
123
+
124
+ expect(input["type"]).to eq("range")
125
+ expect(input["value"]).to eq("50")
126
+ end
127
+
128
+ it "keeps explicit range type options as range inputs" do
129
+ input = fragment(helper_host.number_field_tag("base_salary", "50", "type" => "range")).at_css("input")
130
+
131
+ expect(input["type"]).to eq("range")
132
+ expect(input["value"]).to eq("50")
133
+ end
134
+ end
135
+
136
+ describe "form builder integration" do
137
+ it "routes f.number_field through the helper override" do
138
+ form_builder = ActionView::Helpers::FormBuilder.new(:job_match, resource, helper_host, {})
139
+ input = fragment(form_builder.number_field(:base_salary)).at_css("input")
140
+
141
+ expect(input["type"]).to eq("text")
142
+ expect(input["inputmode"]).to eq("decimal")
143
+ expect(input["value"]).to eq("123.45")
144
+ end
145
+
146
+ it "keeps f.range_field as a range input" do
147
+ form_builder = ActionView::Helpers::FormBuilder.new(:job_match, resource, helper_host, {})
148
+ input = fragment(form_builder.range_field(:base_salary, value: "50")).at_css("input")
149
+
150
+ expect(input["type"]).to eq("range")
151
+ expect(input["value"]).to eq("50")
152
+ end
153
+ end
154
+ 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.3
4
+ version: 0.6.0
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-25 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,7 +109,9 @@ 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
114
+ - app/helpers/yummy_guide/administrate/number_input_helper.rb
112
115
  - app/views/fields/number/_form.html.erb
113
116
  - app/views/fields/yummy_guide_administrate/area/picture/_form.html.erb
114
117
  - app/views/fields/yummy_guide_administrate/area/picture/_index.html.erb
@@ -128,6 +131,7 @@ files:
128
131
  - lib/yummy/guide/generic/administrate.rb
129
132
  - lib/yummy_guide/administrate.rb
130
133
  - lib/yummy_guide/administrate/engine.rb
134
+ - lib/yummy_guide/administrate/filters.rb
131
135
  - lib/yummy_guide/administrate/version.rb
132
136
  - spec/spec_helper.rb
133
137
  - spec/views/fields/number/form_spec.rb
@@ -139,7 +143,10 @@ files:
139
143
  - spec/yummy_guide/administrate/fields/json_pretty_field_spec.rb
140
144
  - spec/yummy_guide/administrate/fields/version_item_field_spec.rb
141
145
  - spec/yummy_guide/administrate/fields/version_whodunnit_field_spec.rb
146
+ - spec/yummy_guide/administrate/filter_controls_helper_spec.rb
142
147
  - spec/yummy_guide/administrate/filter_form_helper_spec.rb
148
+ - spec/yummy_guide/administrate/filters_spec.rb
149
+ - spec/yummy_guide/administrate/number_input_helper_spec.rb
143
150
  - yummy-guide-generic-administrate.gemspec
144
151
  homepage: https://github.com/yummy-guide/generic-administrate
145
152
  licenses: