ba_spree 1.0.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 (160) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +673 -0
  3. data/README.md +150 -0
  4. data/app/assets/config/manifest.js +1 -0
  5. data/app/assets/javascripts/ba_spree/admin_overrides/query_builder_controller.js +505 -0
  6. data/app/assets/javascripts/ba_spree/storefront/ba_spree_local_time.js +9 -0
  7. data/app/assets/stylesheets/spree/admin/custom.scss +40 -0
  8. data/app/controllers/ba_spree/checkout_controller_decorator.rb +111 -0
  9. data/app/controllers/ba_spree/store_controller_decorator.rb +55 -0
  10. data/app/controllers/concerns/ba_spree/theme_concern_decorator.rb +16 -0
  11. data/app/controllers/spree/admin/assets_controller_decorator.rb +48 -0
  12. data/app/controllers/spree/admin/products_controller_decorator.rb +25 -0
  13. data/app/controllers/spree/admin/promotion_rules_controller_decorator.rb +30 -0
  14. data/app/controllers/spree/admin/users_controller_decorator.rb +34 -0
  15. data/app/controllers/spree/contacts_controller_decorator.rb +23 -0
  16. data/app/controllers/spree/digital_links_controller.rb +61 -0
  17. data/app/controllers/spree/orders_controller_decorator.rb +24 -0
  18. data/app/decorators/ba_spree/product_pricing_decorator.rb +65 -0
  19. data/app/decorators/spree/current_decorator.rb +17 -0
  20. data/app/decorators/spree/money_decorator.rb +51 -0
  21. data/app/decorators/spree/order_email_subscriber_decorator.rb +64 -0
  22. data/app/decorators/spree/page_sections/featured_posts_decorator.rb +17 -0
  23. data/app/helpers/currency_rounding_helper.rb +10 -0
  24. data/app/helpers/spree/admin/filter_params_preservation.rb +130 -0
  25. data/app/helpers/spree/admin/navigation_helper_decorator.rb +23 -0
  26. data/app/helpers/spree/admin/promotion_rules_helper_decorator.rb +33 -0
  27. data/app/helpers/spree/admin/table_helper_decorator.rb +40 -0
  28. data/app/helpers/spree/base_helper_decorator.rb +63 -0
  29. data/app/helpers/spree/checkout_analytics_helper_decorator.rb +32 -0
  30. data/app/javascript/ba_spree/controllers/disable_when_hidden_controller.js +70 -0
  31. data/app/javascript/ba_spree/index.js +11 -0
  32. data/app/javascript/spree/admin/controllers/calendar_range_controller.js +61 -0
  33. data/app/javascript/spree/admin/controllers/slug_form_controller.js +17 -0
  34. data/app/models/ba_spree/address_decorator.rb +43 -0
  35. data/app/models/ba_spree/adjustment_decorator.rb +13 -0
  36. data/app/models/ba_spree/order_decorator.rb +95 -0
  37. data/app/models/ba_spree/order_updater_decorator.rb +33 -0
  38. data/app/models/ba_spree/payment_method_decorator.rb +10 -0
  39. data/app/models/ba_spree/product_decorator.rb +44 -0
  40. data/app/models/ba_spree/store_decorator.rb +23 -0
  41. data/app/models/ba_spree/variant_decorator.rb +49 -0
  42. data/app/models/concerns/spree/adjustment_source_decorator.rb +66 -0
  43. data/app/models/concerns/spree/product_property_metafields.rb +52 -0
  44. data/app/models/spree/adjustable/adjuster/promotion_decorator.rb +16 -0
  45. data/app/models/spree/admin/table/filter_decorator.rb +52 -0
  46. data/app/models/spree/admin/table/filter_group_decorator.rb +64 -0
  47. data/app/models/spree/asset_decorator.rb +102 -0
  48. data/app/models/spree/contact_decorator.rb +22 -0
  49. data/app/models/spree/digital_link_decorator.rb +9 -0
  50. data/app/models/spree/export_decorator.rb +34 -0
  51. data/app/models/spree/line_item_destroy_callback_decorator.rb +30 -0
  52. data/app/models/spree/metafield_definition_decorator.rb +34 -0
  53. data/app/models/spree/option_type_decorator.rb +22 -0
  54. data/app/models/spree/option_value_decorator.rb +19 -0
  55. data/app/models/spree/option_value_variant_decorator.rb +30 -0
  56. data/app/models/spree/order/address_book_decorator.rb +14 -0
  57. data/app/models/spree/order_merger_decorator.rb +29 -0
  58. data/app/models/spree/price_rules/customer_group_rule_decorator.rb +18 -0
  59. data/app/models/spree/price_rules/user_rule_decorator.rb +25 -0
  60. data/app/models/spree/price_rules/volume_rule_decorator.rb +25 -0
  61. data/app/models/spree/price_rules/zone_rule_decorator.rb +25 -0
  62. data/app/models/spree/pricing/batch_resolver.rb +96 -0
  63. data/app/models/spree/promotion_handler/cart_decorator.rb +51 -0
  64. data/app/models/spree/stock/packer_decorator.rb +61 -0
  65. data/app/models/spree/stock/prioritizer_decorator.rb +27 -0
  66. data/app/models/spree/tax_rate_decorator.rb +93 -0
  67. data/app/models/spree/taxon_decorator.rb +42 -0
  68. data/app/models/spree/user_bill_address_sync_decorator.rb +33 -0
  69. data/app/overrides/add_ba_spree_storefront_javascript_importmap_tags.rb +8 -0
  70. data/app/overrides/add_cache_to_product_listing_page.rb +41 -0
  71. data/app/overrides/add_class_to_login_link.rb +6 -0
  72. data/app/overrides/add_confirm_state_to_checkout_content_block.rb.rb +30 -0
  73. data/app/overrides/add_promotions_nav.rb +8 -0
  74. data/app/overrides/add_turbo_frame_to_admin_order_line_item_edit_link.rb +23 -0
  75. data/app/overrides/add_turbo_frame_to_promotion_action_edit_link.rb +20 -0
  76. data/app/overrides/add_turbo_stream_format_to_cart_path_in_cart_pane.rb +8 -0
  77. data/app/overrides/ba_spree_local_time_add_custom_javascript_import_map_tags.rb +8 -0
  78. data/app/overrides/disable_hidden_bill_address_fields_on_payment.rb +15 -0
  79. data/app/overrides/disable_turbo_on_user_registration_form.rb +18 -0
  80. data/app/overrides/fix_coupon_apply_button_layout.rb +7 -0
  81. data/app/overrides/hide_gift_cards_and_store_credits_from_account_nav.rb +11 -0
  82. data/app/overrides/hide_shipping_message_for_digital_only_orders.rb +15 -0
  83. data/app/overrides/localize_apply_button_text.rb +6 -0
  84. data/app/overrides/localize_newsletter_button_text.rb +6 -0
  85. data/app/overrides/replace_announcement_bar_text.rb +6 -0
  86. data/app/overrides/replace_button_edit_checkout.rb +10 -0
  87. data/app/overrides/replace_checkout_delivery_info.rb +6 -0
  88. data/app/overrides/replace_checkout_pay_button_with_dynamic_label.rb +14 -0
  89. data/app/overrides/replace_variant_not_available_button.rb +19 -0
  90. data/app/overrides/set_logo_width_height_in_admin_store_basic_form.rb +12 -0
  91. data/app/overrides/show_breadcrumbs_on_taxon_page.rb +30 -0
  92. data/app/overrides/show_individual_adjustments_in_order_details.rb +18 -0
  93. data/app/overrides/show_only_selected_payment_method_on_confirm.rb +29 -0
  94. data/app/overrides/spree/admin/preserve_filter_params_across_table_forms.rb +64 -0
  95. data/app/overrides/spree/admin/promotion_rules/_promotion_rule/set_turbo_frame_on_edit_link.html.erb.deface +11 -0
  96. data/app/overrides/spree/admin/stores/add_special_instruction_message_settings.rb +17 -0
  97. data/app/overrides/use_absolute_time_for_order_header_completed_at.rb +15 -0
  98. data/app/presenters/spree/mega_nav_presenter_decorator.rb +26 -0
  99. data/app/services/ba_spree/setup_service.rb +144 -0
  100. data/app/services/spree/assets/position_normalizer.rb +67 -0
  101. data/app/services/spree/products/duplicator_decorator.rb +39 -0
  102. data/app/services/spree/products/prepare_nested_attributes_decorator.rb +58 -0
  103. data/app/views/spree/addresses/edit.html.erb +64 -0
  104. data/app/views/spree/admin/orders/_order.html.erb +32 -0
  105. data/app/views/spree/admin/orders/_summary.html.erb +122 -0
  106. data/app/views/spree/admin/shared/_custom_head.html.erb +56 -0
  107. data/app/views/spree/admin/shared/sidebar/_promotions_nav.html.erb +55 -0
  108. data/app/views/spree/admin/stores/_special_instruction_message_settings.html.erb +14 -0
  109. data/app/views/spree/checkout/_accept_marketing_checkbox.html.erb +40 -0
  110. data/app/views/spree/checkout/_address.html.erb +135 -0
  111. data/app/views/spree/checkout/_confirm.html.erb +25 -0
  112. data/app/views/spree/checkout/_line_item.html.erb +26 -0
  113. data/app/views/spree/checkout/_payment_method_confirm.html.erb +8 -0
  114. data/app/views/spree/checkout/_payment_methods.html.erb +88 -0
  115. data/app/views/spree/checkout/_summary.html.erb +146 -0
  116. data/app/views/spree/shared/_purchased_items_table.html.erb +60 -0
  117. data/app/views/spree/shared/_purchased_items_table.text.erb +24 -0
  118. data/app/views/themes/default/spree/orders/_empty.html.erb +14 -0
  119. data/app/views/themes/default/spree/orders/_summary.html.erb +87 -0
  120. data/app/views/themes/default/spree/orders/edit.turbo_stream.erb +10 -0
  121. data/app/views/themes/default/spree/page_sections/_featured_taxons.html.erb +38 -0
  122. data/app/views/themes/default/spree/page_sections/_header.html.erb +124 -0
  123. data/app/views/themes/default/spree/page_sections/_newsletter.html.erb +4 -0
  124. data/app/views/themes/default/spree/page_sections/_post_details.html.erb +22 -0
  125. data/app/views/themes/default/spree/page_sections/_product_details.html.erb +88 -0
  126. data/app/views/themes/default/spree/page_sections/_product_grid.html.erb +58 -0
  127. data/app/views/themes/default/spree/page_sections/nav/_desktop.html.erb +94 -0
  128. data/app/views/themes/default/spree/page_sections/nav/_mobile.html.erb +148 -0
  129. data/app/views/themes/default/spree/posts/_post.html.erb +16 -0
  130. data/app/views/themes/default/spree/products/_description.html.erb +7 -0
  131. data/app/views/themes/default/spree/products/_details.html.erb +65 -0
  132. data/app/views/themes/default/spree/products/_json_ld_variant.html.erb +15 -0
  133. data/app/views/themes/default/spree/products/_price.html.erb +76 -0
  134. data/app/views/themes/default/spree/products/_swiper.html.erb +90 -0
  135. data/app/views/themes/default/spree/shared/_account_pane.html.erb +18 -0
  136. data/app/views/themes/default/spree/shared/_cart_icon.html.erb +19 -0
  137. data/app/views/themes/default/spree/shared/_line_item_options.html.erb +9 -0
  138. data/app/views/themes/default/spree/shared/_logo.html.erb +39 -0
  139. data/app/views/themes/default/spree/shared/_nav_taxon_square_image.html.erb +8 -0
  140. data/app/views/themes/default/spree/shared/_order_shipment.html.erb +79 -0
  141. data/app/views/themes/default/spree/shared/_search.html.erb +33 -0
  142. data/app/views/themes/default/spree/shared/icons/_cart.html.erb +6 -0
  143. data/app/views/themes/default/spree/shared/icons/_cart_48.html.erb +8 -0
  144. data/app/views/themes/default/spree/shared/icons/_heart.html.erb +15 -0
  145. data/config/importmap.rb +12 -0
  146. data/config/initializers/admin_navigation.rb +43 -0
  147. data/config/initializers/spree.rb +6 -0
  148. data/config/initializers/spree_admin_label_i18n_alias.rb +52 -0
  149. data/config/initializers/spree_admin_tables.rb +57 -0
  150. data/config/initializers/spree_permitted_attributes.rb +1 -0
  151. data/config/locales/en.yml +15 -0
  152. data/config/locales/ja.yml +18 -0
  153. data/config/locales/spree.en.yml +4 -0
  154. data/config/locales/spree.ja.yml +3493 -0
  155. data/lib/ba_spree/engine.rb +41 -0
  156. data/lib/ba_spree/spree_events_dedup_patch.rb +44 -0
  157. data/lib/ba_spree/version.rb +3 -0
  158. data/lib/ba_spree.rb +20 -0
  159. data/lib/generators/ba_spree/install/install_generator.rb +85 -0
  160. metadata +594 -0
data/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # ba_spree
2
+
3
+ **Run a Japanese storefront on Spree, out of the box.**
4
+
5
+ Spree Commerce works fine as-is, but selling in Japan means building a lot of
6
+ things yourself: address fields in Japanese order, prefectures, tax-inclusive
7
+ pricing, bank transfer and cash-on-delivery payments.
8
+
9
+ ba_spree bundles all of that. It also ships roughly 3,000 Japanese translation
10
+ keys, so both the admin and the storefront are in Japanese from the moment you
11
+ install it.
12
+
13
+ Supported Spree version: **5.3.6**
14
+
15
+ ---
16
+
17
+ ## Try it
18
+
19
+ [ba_spree_starter](https://github.com/be-agile/ba_spree_starter) is a complete,
20
+ runnable application.
21
+
22
+ ```bash
23
+ git clone https://github.com/be-agile/ba_spree_starter.git
24
+ cd ba_spree_starter
25
+ bundle install
26
+
27
+ bin/rails db:migrate
28
+ bin/rails db:seed # country, prefectures, tax, shipping, JPY
29
+ bin/rails spree_sample:load # Japanese sample products and orders
30
+
31
+ bin/rails server
32
+ ```
33
+
34
+ `db:seed` and `spree_sample:load` are two separate commands.
35
+ The sample data lives in the latter.
36
+
37
+ ## Add it to an existing app
38
+
39
+ ```ruby
40
+ # Gemfile
41
+ gem 'ba_spree'
42
+ ```
43
+
44
+ ```bash
45
+ bundle install
46
+ bin/rails g ba_spree:install
47
+ ```
48
+
49
+ `ba_spree:install` copies the migrations from every bundled engine in one go
50
+ (36 migrations across 9 engines). You do not need to run each engine's
51
+ generator separately.
52
+
53
+ To set a store up for Japan, call this from `db/seeds.rb` or similar:
54
+
55
+ ```ruby
56
+ BaSpree::SetupService.setup(store: Spree::Store.default)
57
+ ```
58
+
59
+ That configures:
60
+
61
+ - Country Japan, and all 47 prefectures
62
+ - A "日本" zone
63
+ - Consumption tax at 10% / 8% (reduced rate) / exempt
64
+ - A flat-rate shipping method with Sagawa tracking URLs
65
+ - JPY currency and `ja` locale
66
+
67
+ It deliberately does not touch the store name, URL or email addresses — those
68
+ are specific to your shop, so set them yourself.
69
+
70
+ ## What you get
71
+
72
+ ba_spree itself is a thin wrapper. The substance is these 20 engines, all
73
+ pulled in by a single `gem 'ba_spree'`.
74
+
75
+ ### Japanese commerce conventions
76
+
77
+ | gem | What it does |
78
+ |---|---|
79
+ | `spree_address_format_i18n` | Japanese address format (name order, prefectures, field order) |
80
+ | `spree_zip_autocomplete` | Autofill an address from a postal code |
81
+ | `ba_spree_bank_transfer` | Bank transfer payments |
82
+ | `ba_spree_cash_on_delivery` | Cash on delivery |
83
+ | `spree_np_atobarai` | NP deferred payment (admin screens, CSV export, invoice delivery options) |
84
+ | `spree_gmo_pg` | GMO Payment Gateway integration |
85
+ | `spree_direct_debit` | Direct debit (B2B, processed through the banking system) |
86
+ | `spree_custom_email` | Customisable email templates |
87
+
88
+ ### General extensions
89
+
90
+ | gem | What it does |
91
+ |---|---|
92
+ | `ba_spree_loyalty_points` | Earning and spending loyalty points |
93
+ | `spree_materials` | Image / PDF asset management with shareable links for product descriptions |
94
+ | `ba_spree_related_products` | Relate products to each other, with multiple relation types |
95
+ | `spree_products_payment_methods` | Restrict payment methods per product |
96
+ | `spree_limit_order_quantity` | Minimum and maximum order quantity per product or variant |
97
+ | `spree_option_type_description` | Add descriptions to option types and show them on the product page |
98
+ | `spree_search_with_description` | Include product descriptions in search |
99
+ | `spree_auto_capture_digital` | Auto-capture orders containing only digital products |
100
+ | `spree_digital_payment_notice` | Show payment notices for orders containing digital products |
101
+ | `spree_checkout_signup_promotion` | Prompt sign-up / login at the checkout address step |
102
+
103
+ ### Analytics
104
+
105
+ | gem | What it does |
106
+ |---|---|
107
+ | `ba_spree_google_analytics` | Google Analytics tracking (fork of the official Spree gem) |
108
+ | `spree_yahoo_ads` | Yahoo! Ads conversion tracking |
109
+
110
+ ## Where behaviour differs from upstream Spree
111
+
112
+ Some behaviour is changed to suit the Japanese market. We plan to make these
113
+ switchable through Spree preferences in future.
114
+
115
+ | Behaviour | Upstream | ba_spree |
116
+ |---|---|---|
117
+ | Multiple promotions | Keeps only the best one | Allows them to combine |
118
+ | Company name field | Controlled by `Store#prefers_company_field_enabled?` | Always shown |
119
+ | Store lookup with no default flag | First record in the database | Returns a new default instance |
120
+ | Image variant `preprocessed` | 5.3 default | Can be disabled via an environment variable |
121
+ | MetafieldDefinition keys | Run through `parameterize` | Japanese text preserved as-is |
122
+ | Cart line item thumbnail | 128px named variant | 60px dynamic variant |
123
+
124
+ ## Requirements
125
+
126
+ - Ruby >= 3.1.4
127
+ - Spree 5.3.6
128
+ - Rails 8.x
129
+ - MySQL or PostgreSQL
130
+
131
+ The Spree version is pinned to 5.3.6. From 5.4 onwards the Rails storefront
132
+ (`spree_storefront`) moved to a separate repository and stopped receiving
133
+ updates at 5.4.6, and ba_spree overrides 30 of its views.
134
+
135
+ ## Licence
136
+
137
+ AGPL-3.0-or-later. See [LICENSE](LICENSE) for the full text.
138
+
139
+ ba_spree copies and modifies parts of the Spree 5.3.6 source. Of those,
140
+ `spree_storefront` and `spree_admin` 5.3.6 are distributed under
141
+ AGPL-3.0-or-later alone, so ba_spree is AGPL too.
142
+
143
+ Engines that originated as forks retain the original BSD-3-Clause copyright
144
+ notice and terms, as clause 1 of that licence requires.
145
+
146
+ ## Development
147
+
148
+ This repository is a distribution copy of `engines/ba_spree` from
149
+ [giga-repeat](https://github.com/be-agile/giga-repeat).
150
+ Bug reports and suggestions are welcome in the issue tracker.
@@ -0,0 +1 @@
1
+ //= link spree/admin/custom.css
@@ -0,0 +1,505 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = [
5
+ "container",
6
+ "filtersContainer",
7
+ "groupsContainer",
8
+ "hiddenInput",
9
+ "filterTemplate",
10
+ "groupTemplate",
11
+ "combinatorButton",
12
+ "combinatorLabel",
13
+ "form"
14
+ ]
15
+
16
+ static values = {
17
+ fields: Array,
18
+ operators: Array
19
+ }
20
+
21
+ connect() {
22
+ this.rootCombinator = "and"
23
+ this.loadState()
24
+ }
25
+
26
+ // Called before form submission to add ransack params
27
+ // ba_spree override: 本家は送信直前に query_state から ransack の hidden input (q[...]) を
28
+ // 組み立てて form に足していたが、これは不要なうえ有害だった。
29
+ //
30
+ // - 不要: コントローラの TableConcern#process_table_query_state が query_state を
31
+ // params[:q] にマージし直すため、q はサーバ側で再生成される
32
+ // (実際に /admin/orders?query_state={...} だけで絞り込みが成立することを確認済み)
33
+ // - 有害: 「または」グループでは q[g[0][m]] という Rack が正しくパースできない名前を
34
+ // 生成してしまい (Rack::Utils.parse_nested_query が "g[0" という壊れたキーにする)、
35
+ // その壊れたキーが hidden やリンクを通じて名前を変質させながら増殖していた
36
+ //
37
+ // 送信時には query_state さえ最新であればよい。
38
+ // @see https://github.com/be-agile/giga-repeat/issues/1262
39
+ submitForm(_event) {
40
+ this.updateHiddenInput()
41
+ }
42
+
43
+ loadState() {
44
+ const stateJson = this.hiddenInputTarget.value
45
+ if (stateJson && stateJson !== "{}") {
46
+ try {
47
+ const state = JSON.parse(stateJson)
48
+ this.renderState(state)
49
+ } catch (e) {
50
+ console.error("Failed to parse query state:", e)
51
+ }
52
+ }
53
+ }
54
+
55
+ renderState(state) {
56
+ // ba_spree override: 再構築の前にコンテナを空にする。
57
+ //
58
+ // 本家はサーバ側で空の filtersContainer を描画し、connect() → loadState() →
59
+ // renderState() が query_state を読んで行を append する(クリアしない)。
60
+ // Spree admin は Turbo Drive のページキャッシュが有効なため、注文詳細へ遷移して
61
+ // ブラウザ「戻る」をすると、Turbo は「JS が描画済みの行を含むスナップショット」を
62
+ // 復元し、その後 Stimulus が再 connect して loadState() がもう一度 append する。
63
+ // 結果、ドロワーのフィルタ行が倍増し(バッジと hidden query_state はサーバ描画のまま
64
+ // なので開くまで気づかない)、適用すると query_state.filters が重複して増殖していく。
65
+ //
66
+ // renderState の冒頭でコンテナを空にすることで、再 connect / キャッシュ復元でも
67
+ // query_state から冪等に再構築されるようにする。
68
+ // @see https://github.com/be-agile/giga-repeat/issues/1262
69
+ this.filtersContainerTarget.innerHTML = ""
70
+ this.groupsContainerTarget.innerHTML = ""
71
+
72
+ // Set root combinator
73
+ if (state.combinator) {
74
+ this.rootCombinator = state.combinator
75
+ this.updateCombinatorDisplay()
76
+ }
77
+
78
+ // Render filters
79
+ if (state.filters && state.filters.length > 0) {
80
+ state.filters.forEach(filter => {
81
+ this.addFilterWithData(filter, this.filtersContainerTarget)
82
+ })
83
+ }
84
+
85
+ // Render groups
86
+ if (state.groups && state.groups.length > 0) {
87
+ state.groups.forEach(group => {
88
+ this.addGroupWithData(group)
89
+ })
90
+ }
91
+ }
92
+
93
+ addFilter(event) {
94
+ event.preventDefault()
95
+ this.addFilterWithData({}, this.filtersContainerTarget)
96
+ this.updateHiddenInput()
97
+ }
98
+
99
+ addFilterToGroup(event) {
100
+ event.preventDefault()
101
+ const groupEl = event.target.closest("[data-group-id]")
102
+ const filtersContainer = groupEl.querySelector("[data-group-filters-container]")
103
+ this.addFilterWithData({}, filtersContainer)
104
+ this.updateHiddenInput()
105
+ }
106
+
107
+ addFilterWithData(filterData, container) {
108
+ const template = this.filterTemplateTarget.content.cloneNode(true)
109
+ const row = template.querySelector("[data-filter-id]")
110
+ row.dataset.filterId = filterData.id || this.generateId()
111
+
112
+ // Populate field options
113
+ const fieldSelect = row.querySelector("[data-field-select]")
114
+ this.fieldsValue.forEach(field => {
115
+ const option = document.createElement("option")
116
+ option.value = field.key
117
+ option.textContent = field.label
118
+ if (filterData.field === field.key) {
119
+ option.selected = true
120
+ }
121
+ fieldSelect.appendChild(option)
122
+ })
123
+
124
+ // If we have a field selected, update operators and value
125
+ if (filterData.field) {
126
+ const fieldConfig = this.fieldsValue.find(f => f.key === filterData.field)
127
+ if (fieldConfig) {
128
+ this.updateOperatorOptions(row, fieldConfig.operators, filterData.operator)
129
+ this.updateValueInput(row, fieldConfig, filterData.value, filterData.operator)
130
+ }
131
+ }
132
+
133
+ container.appendChild(row)
134
+ }
135
+
136
+ addOrGroup(event) {
137
+ event.preventDefault()
138
+ this.addGroupWithData({ combinator: "or", filters: [], groups: [] })
139
+ this.updateHiddenInput()
140
+ }
141
+
142
+ addGroupWithData(groupData) {
143
+ const template = this.groupTemplateTarget.content.cloneNode(true)
144
+ const group = template.querySelector("[data-group-id]")
145
+ group.dataset.groupId = groupData.id || this.generateId()
146
+ group.dataset.combinator = groupData.combinator || "or"
147
+
148
+ // Update combinator label
149
+ const label = group.querySelector("[data-group-combinator-label]")
150
+ if (label) {
151
+ label.textContent = (groupData.combinator || "or").toUpperCase()
152
+ }
153
+
154
+ // Add existing filters to the group
155
+ if (groupData.filters && groupData.filters.length > 0) {
156
+ const filtersContainer = group.querySelector("[data-group-filters-container]")
157
+ groupData.filters.forEach(filter => {
158
+ this.addFilterWithData(filter, filtersContainer)
159
+ })
160
+ }
161
+
162
+ this.groupsContainerTarget.appendChild(group)
163
+ }
164
+
165
+ removeFilter(event) {
166
+ event.preventDefault()
167
+ const filterRow = event.target.closest("[data-filter-id]")
168
+ if (filterRow) {
169
+ filterRow.remove()
170
+ this.updateHiddenInput()
171
+ }
172
+ }
173
+
174
+ removeGroup(event) {
175
+ event.preventDefault()
176
+ const group = event.target.closest("[data-group-id]")
177
+ if (group) {
178
+ group.remove()
179
+ this.updateHiddenInput()
180
+ }
181
+ }
182
+
183
+ toggleCombinator(event) {
184
+ event.preventDefault()
185
+ this.rootCombinator = this.rootCombinator === "and" ? "or" : "and"
186
+ this.updateCombinatorDisplay()
187
+ this.updateHiddenInput()
188
+ }
189
+
190
+ toggleGroupCombinator(event) {
191
+ event.preventDefault()
192
+ const group = event.target.closest("[data-group-id]")
193
+ const currentCombinator = group.dataset.combinator
194
+ const newCombinator = currentCombinator === "and" ? "or" : "and"
195
+
196
+ group.dataset.combinator = newCombinator
197
+ const label = group.querySelector("[data-group-combinator-label]")
198
+ if (label) {
199
+ label.textContent = newCombinator.toUpperCase()
200
+ }
201
+
202
+ this.updateHiddenInput()
203
+ }
204
+
205
+ updateCombinatorDisplay() {
206
+ if (this.hasCombinatorLabelTarget) {
207
+ this.combinatorLabelTarget.textContent = this.rootCombinator.toUpperCase()
208
+ }
209
+ }
210
+
211
+ onFieldChange(event) {
212
+ const filterRow = event.target.closest("[data-filter-id]")
213
+ const field = event.target.value
214
+ const fieldConfig = this.fieldsValue.find(f => f.key === field)
215
+
216
+ if (fieldConfig) {
217
+ this.updateOperatorOptions(filterRow, fieldConfig.operators)
218
+ const operator = filterRow.querySelector("[data-operator-select]")?.value
219
+ this.updateValueInput(filterRow, fieldConfig, null, operator)
220
+ }
221
+
222
+ this.updateHiddenInput()
223
+ }
224
+
225
+ onOperatorChange(event) {
226
+ const filterRow = event.target.closest("[data-filter-id]")
227
+ const operator = event.target.value
228
+ const noValueOperators = ["null", "not_null"]
229
+
230
+ const valueContainer = filterRow.querySelector("[data-value-container]")
231
+ if (noValueOperators.includes(operator)) {
232
+ valueContainer.style.display = "none"
233
+ } else {
234
+ valueContainer.style.display = ""
235
+ // Rebuild value input when switching between single/multi operators
236
+ const field = filterRow.querySelector("[data-field-select]")?.value
237
+ const fieldConfig = this.fieldsValue.find(f => f.key === field)
238
+ if (fieldConfig && (fieldConfig.type === "select" || fieldConfig.type === "status")) {
239
+ this.updateValueInput(filterRow, fieldConfig, null, operator)
240
+ }
241
+ }
242
+
243
+ this.updateHiddenInput()
244
+ }
245
+
246
+ onValueChange() {
247
+ this.updateHiddenInput()
248
+ }
249
+
250
+ updateOperatorOptions(filterRow, operators, selectedOperator = null) {
251
+ const operatorSelect = filterRow.querySelector("[data-operator-select]")
252
+ operatorSelect.innerHTML = ""
253
+
254
+ // ba_spree override: 本家はラベルを JS にハードコード(英語)していたため
255
+ // フィールド選択後に演算子セレクトが英語化していた。サーバから渡される
256
+ // operatorsValue (operators_for_select の出力 = i18n 済みラベル) を参照する。
257
+ const operatorLabels = {}
258
+ this.operatorsValue.forEach(op => {
259
+ operatorLabels[op.value] = op.label
260
+ })
261
+
262
+ operators.forEach(op => {
263
+ const option = document.createElement("option")
264
+ option.value = op
265
+ option.textContent = operatorLabels[op] || op
266
+ if (selectedOperator === op) {
267
+ option.selected = true
268
+ }
269
+ operatorSelect.appendChild(option)
270
+ })
271
+ }
272
+
273
+ updateValueInput(filterRow, fieldConfig, existingValue = null, operator = null) {
274
+ const valueContainer = filterRow.querySelector("[data-value-container]")
275
+ valueContainer.innerHTML = ""
276
+
277
+ // Check if operator requires multi-select
278
+ const isMultiOperator = ["in", "not_in"].includes(operator)
279
+
280
+ let input
281
+ switch (fieldConfig.type) {
282
+ case "date":
283
+ input = document.createElement("input")
284
+ input.type = "date"
285
+ input.className = "form-input text-sm"
286
+ break
287
+ case "datetime":
288
+ input = document.createElement("input")
289
+ input.type = "datetime-local"
290
+ input.className = "form-input text-sm"
291
+ break
292
+ case "number":
293
+ case "currency":
294
+ input = document.createElement("input")
295
+ input.type = "number"
296
+ input.className = "form-input text-sm"
297
+ input.step = fieldConfig.type === "currency" ? "0.01" : "1"
298
+ break
299
+ case "boolean":
300
+ input = document.createElement("select")
301
+ input.className = "form-select text-sm"
302
+ input.innerHTML = `
303
+ <option value="true">Yes</option>
304
+ <option value="false">No</option>
305
+ `
306
+ break
307
+ case "status":
308
+ case "select":
309
+ // For in/not_in operators, use multi-select with tom-select
310
+ if (isMultiOperator) {
311
+ const wrapper = document.createElement("div")
312
+ wrapper.className = "w-full"
313
+ wrapper.dataset.controller = "select"
314
+ wrapper.dataset.selectMultipleValue = "true"
315
+
316
+ // Build options array for tom-select
317
+ const selectOptions = []
318
+ if (fieldConfig.value_options) {
319
+ fieldConfig.value_options.forEach(opt => {
320
+ selectOptions.push({
321
+ id: opt.value || opt,
322
+ name: opt.label || opt
323
+ })
324
+ })
325
+ }
326
+ wrapper.dataset.selectOptionsValue = JSON.stringify(selectOptions)
327
+
328
+ input = document.createElement("select")
329
+ input.className = "text-sm"
330
+ input.multiple = true
331
+ input.dataset.selectTarget = "input"
332
+ input.dataset.valueInput = ""
333
+
334
+ // Restore existing values for multi-select
335
+ if (existingValue !== null && existingValue !== undefined && Array.isArray(existingValue)) {
336
+ const selectedIds = existingValue.map(item => {
337
+ if (typeof item === 'object') {
338
+ return item.id
339
+ } else {
340
+ return item
341
+ }
342
+ })
343
+ wrapper.dataset.selectActiveOptionValue = JSON.stringify(selectedIds)
344
+ }
345
+
346
+ input.dataset.action = "change->query-builder#onValueChange"
347
+ wrapper.appendChild(input)
348
+ valueContainer.appendChild(wrapper)
349
+ return // Early return since we already appended
350
+ } else {
351
+ // Single select for eq/not_eq operators
352
+ input = document.createElement("select")
353
+ input.className = "form-select text-sm"
354
+ input.innerHTML = '<option value="">Select...</option>'
355
+ if (fieldConfig.value_options) {
356
+ fieldConfig.value_options.forEach(opt => {
357
+ const option = document.createElement("option")
358
+ option.value = opt.value || opt
359
+ option.textContent = opt.label || opt
360
+ input.appendChild(option)
361
+ })
362
+ }
363
+ }
364
+ break
365
+ case "autocomplete":
366
+ // Create a wrapper div with select controller
367
+ const wrapper = document.createElement("div")
368
+ wrapper.className = "w-full"
369
+ wrapper.dataset.controller = "select"
370
+ wrapper.dataset.selectMultipleValue = "true"
371
+
372
+ if (fieldConfig.search_url) {
373
+ wrapper.dataset.selectUrlValue = fieldConfig.search_url
374
+ // Use remote search mode so it doesn't pre-fetch and overwrite our preloaded options
375
+ wrapper.dataset.selectRemoteSearchValue = "true"
376
+ }
377
+
378
+ // Use name as value field for tags (tags_name ransack attribute expects tag names)
379
+ const useNameAsValue = fieldConfig.key && fieldConfig.key.includes("name")
380
+ if (useNameAsValue) {
381
+ wrapper.dataset.selectValueFieldValue = "name"
382
+ }
383
+
384
+ input = document.createElement("select")
385
+ input.className = "text-sm"
386
+ input.multiple = true
387
+ input.dataset.selectTarget = "input"
388
+ input.dataset.valueInput = "" // Mark as value input for serialization
389
+
390
+ // Restore existing values - we store as array of {id, name} objects
391
+ // Use remoteSearchActiveOption which properly preloads options and selects them
392
+ if (existingValue !== null && existingValue !== undefined && Array.isArray(existingValue) && existingValue.length > 0) {
393
+ const preloadedOptions = existingValue.map(item => {
394
+ if (typeof item === 'object') {
395
+ return { id: useNameAsValue ? item.name : item.id, name: item.name }
396
+ } else {
397
+ return { id: item, name: item }
398
+ }
399
+ })
400
+ wrapper.dataset.selectRemoteSearchActiveOptionValue = JSON.stringify(preloadedOptions)
401
+ }
402
+
403
+ // Set up change listener on select element directly
404
+ input.dataset.action = "change->query-builder#onValueChange"
405
+
406
+ wrapper.appendChild(input)
407
+ valueContainer.appendChild(wrapper)
408
+
409
+ return // Early return since we already appended
410
+ default:
411
+ input = document.createElement("input")
412
+ input.type = "text"
413
+ input.className = "form-input text-sm"
414
+ input.placeholder = "Enter value..."
415
+ }
416
+
417
+ input.dataset.valueInput = ""
418
+ input.dataset.action = "input->query-builder#onValueChange change->query-builder#onValueChange"
419
+
420
+ if (existingValue !== null && existingValue !== undefined) {
421
+ input.value = existingValue
422
+ }
423
+
424
+ valueContainer.appendChild(input)
425
+ }
426
+
427
+ clear(event) {
428
+ event.preventDefault()
429
+ this.filtersContainerTarget.innerHTML = ""
430
+ this.groupsContainerTarget.innerHTML = ""
431
+ this.rootCombinator = "and"
432
+ this.updateCombinatorDisplay()
433
+ this.updateHiddenInput()
434
+ }
435
+
436
+ updateHiddenInput() {
437
+ const state = this.serializeState()
438
+ this.hiddenInputTarget.value = JSON.stringify(state)
439
+ }
440
+
441
+ serializeState() {
442
+ const filters = this.serializeFilters(this.filtersContainerTarget)
443
+ const groups = this.serializeGroups()
444
+
445
+ return {
446
+ id: "root",
447
+ combinator: this.rootCombinator,
448
+ filters: filters,
449
+ groups: groups
450
+ }
451
+ }
452
+
453
+ serializeFilters(container) {
454
+ const filters = []
455
+ container.querySelectorAll(":scope > [data-filter-id]").forEach(row => {
456
+ // Handle multi-select (tom-select) values
457
+ const valueInput = row.querySelector("[data-value-input]")
458
+ let value = null
459
+
460
+ if (valueInput) {
461
+ if (valueInput.multiple) {
462
+ // Get all selected options for multi-select
463
+ // Store as objects with id and name for autocomplete restoration
464
+ value = Array.from(valueInput.selectedOptions).map(opt => ({
465
+ id: opt.value,
466
+ name: opt.textContent
467
+ }))
468
+ } else {
469
+ value = valueInput.value
470
+ }
471
+ }
472
+
473
+ const filter = {
474
+ id: row.dataset.filterId,
475
+ field: row.querySelector("[data-field-select]")?.value,
476
+ operator: row.querySelector("[data-operator-select]")?.value,
477
+ value: value
478
+ }
479
+ if (filter.field) {
480
+ filters.push(filter)
481
+ }
482
+ })
483
+ return filters
484
+ }
485
+
486
+ serializeGroups() {
487
+ const groups = []
488
+ this.groupsContainerTarget.querySelectorAll(":scope > [data-group-id]").forEach(groupEl => {
489
+ const filtersContainer = groupEl.querySelector("[data-group-filters-container]")
490
+ const group = {
491
+ id: groupEl.dataset.groupId,
492
+ combinator: groupEl.dataset.combinator || "or",
493
+ filters: this.serializeFilters(filtersContainer),
494
+ groups: []
495
+ }
496
+ groups.push(group)
497
+ })
498
+ return groups
499
+ }
500
+
501
+ generateId() {
502
+ return Math.random().toString(36).substring(2, 10)
503
+ }
504
+
505
+ }
@@ -0,0 +1,9 @@
1
+ import LocalTime from "local-time";
2
+
3
+ LocalTime.start();
4
+
5
+ document.addEventListener("turbo:morph", () => {
6
+ LocalTime.run();
7
+ });
8
+
9
+ document.addEventListener('turbo:load', () => { LocalTime.run(); });