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.
- checksums.yaml +7 -0
- data/LICENSE +673 -0
- data/README.md +150 -0
- data/app/assets/config/manifest.js +1 -0
- data/app/assets/javascripts/ba_spree/admin_overrides/query_builder_controller.js +505 -0
- data/app/assets/javascripts/ba_spree/storefront/ba_spree_local_time.js +9 -0
- data/app/assets/stylesheets/spree/admin/custom.scss +40 -0
- data/app/controllers/ba_spree/checkout_controller_decorator.rb +111 -0
- data/app/controllers/ba_spree/store_controller_decorator.rb +55 -0
- data/app/controllers/concerns/ba_spree/theme_concern_decorator.rb +16 -0
- data/app/controllers/spree/admin/assets_controller_decorator.rb +48 -0
- data/app/controllers/spree/admin/products_controller_decorator.rb +25 -0
- data/app/controllers/spree/admin/promotion_rules_controller_decorator.rb +30 -0
- data/app/controllers/spree/admin/users_controller_decorator.rb +34 -0
- data/app/controllers/spree/contacts_controller_decorator.rb +23 -0
- data/app/controllers/spree/digital_links_controller.rb +61 -0
- data/app/controllers/spree/orders_controller_decorator.rb +24 -0
- data/app/decorators/ba_spree/product_pricing_decorator.rb +65 -0
- data/app/decorators/spree/current_decorator.rb +17 -0
- data/app/decorators/spree/money_decorator.rb +51 -0
- data/app/decorators/spree/order_email_subscriber_decorator.rb +64 -0
- data/app/decorators/spree/page_sections/featured_posts_decorator.rb +17 -0
- data/app/helpers/currency_rounding_helper.rb +10 -0
- data/app/helpers/spree/admin/filter_params_preservation.rb +130 -0
- data/app/helpers/spree/admin/navigation_helper_decorator.rb +23 -0
- data/app/helpers/spree/admin/promotion_rules_helper_decorator.rb +33 -0
- data/app/helpers/spree/admin/table_helper_decorator.rb +40 -0
- data/app/helpers/spree/base_helper_decorator.rb +63 -0
- data/app/helpers/spree/checkout_analytics_helper_decorator.rb +32 -0
- data/app/javascript/ba_spree/controllers/disable_when_hidden_controller.js +70 -0
- data/app/javascript/ba_spree/index.js +11 -0
- data/app/javascript/spree/admin/controllers/calendar_range_controller.js +61 -0
- data/app/javascript/spree/admin/controllers/slug_form_controller.js +17 -0
- data/app/models/ba_spree/address_decorator.rb +43 -0
- data/app/models/ba_spree/adjustment_decorator.rb +13 -0
- data/app/models/ba_spree/order_decorator.rb +95 -0
- data/app/models/ba_spree/order_updater_decorator.rb +33 -0
- data/app/models/ba_spree/payment_method_decorator.rb +10 -0
- data/app/models/ba_spree/product_decorator.rb +44 -0
- data/app/models/ba_spree/store_decorator.rb +23 -0
- data/app/models/ba_spree/variant_decorator.rb +49 -0
- data/app/models/concerns/spree/adjustment_source_decorator.rb +66 -0
- data/app/models/concerns/spree/product_property_metafields.rb +52 -0
- data/app/models/spree/adjustable/adjuster/promotion_decorator.rb +16 -0
- data/app/models/spree/admin/table/filter_decorator.rb +52 -0
- data/app/models/spree/admin/table/filter_group_decorator.rb +64 -0
- data/app/models/spree/asset_decorator.rb +102 -0
- data/app/models/spree/contact_decorator.rb +22 -0
- data/app/models/spree/digital_link_decorator.rb +9 -0
- data/app/models/spree/export_decorator.rb +34 -0
- data/app/models/spree/line_item_destroy_callback_decorator.rb +30 -0
- data/app/models/spree/metafield_definition_decorator.rb +34 -0
- data/app/models/spree/option_type_decorator.rb +22 -0
- data/app/models/spree/option_value_decorator.rb +19 -0
- data/app/models/spree/option_value_variant_decorator.rb +30 -0
- data/app/models/spree/order/address_book_decorator.rb +14 -0
- data/app/models/spree/order_merger_decorator.rb +29 -0
- data/app/models/spree/price_rules/customer_group_rule_decorator.rb +18 -0
- data/app/models/spree/price_rules/user_rule_decorator.rb +25 -0
- data/app/models/spree/price_rules/volume_rule_decorator.rb +25 -0
- data/app/models/spree/price_rules/zone_rule_decorator.rb +25 -0
- data/app/models/spree/pricing/batch_resolver.rb +96 -0
- data/app/models/spree/promotion_handler/cart_decorator.rb +51 -0
- data/app/models/spree/stock/packer_decorator.rb +61 -0
- data/app/models/spree/stock/prioritizer_decorator.rb +27 -0
- data/app/models/spree/tax_rate_decorator.rb +93 -0
- data/app/models/spree/taxon_decorator.rb +42 -0
- data/app/models/spree/user_bill_address_sync_decorator.rb +33 -0
- data/app/overrides/add_ba_spree_storefront_javascript_importmap_tags.rb +8 -0
- data/app/overrides/add_cache_to_product_listing_page.rb +41 -0
- data/app/overrides/add_class_to_login_link.rb +6 -0
- data/app/overrides/add_confirm_state_to_checkout_content_block.rb.rb +30 -0
- data/app/overrides/add_promotions_nav.rb +8 -0
- data/app/overrides/add_turbo_frame_to_admin_order_line_item_edit_link.rb +23 -0
- data/app/overrides/add_turbo_frame_to_promotion_action_edit_link.rb +20 -0
- data/app/overrides/add_turbo_stream_format_to_cart_path_in_cart_pane.rb +8 -0
- data/app/overrides/ba_spree_local_time_add_custom_javascript_import_map_tags.rb +8 -0
- data/app/overrides/disable_hidden_bill_address_fields_on_payment.rb +15 -0
- data/app/overrides/disable_turbo_on_user_registration_form.rb +18 -0
- data/app/overrides/fix_coupon_apply_button_layout.rb +7 -0
- data/app/overrides/hide_gift_cards_and_store_credits_from_account_nav.rb +11 -0
- data/app/overrides/hide_shipping_message_for_digital_only_orders.rb +15 -0
- data/app/overrides/localize_apply_button_text.rb +6 -0
- data/app/overrides/localize_newsletter_button_text.rb +6 -0
- data/app/overrides/replace_announcement_bar_text.rb +6 -0
- data/app/overrides/replace_button_edit_checkout.rb +10 -0
- data/app/overrides/replace_checkout_delivery_info.rb +6 -0
- data/app/overrides/replace_checkout_pay_button_with_dynamic_label.rb +14 -0
- data/app/overrides/replace_variant_not_available_button.rb +19 -0
- data/app/overrides/set_logo_width_height_in_admin_store_basic_form.rb +12 -0
- data/app/overrides/show_breadcrumbs_on_taxon_page.rb +30 -0
- data/app/overrides/show_individual_adjustments_in_order_details.rb +18 -0
- data/app/overrides/show_only_selected_payment_method_on_confirm.rb +29 -0
- data/app/overrides/spree/admin/preserve_filter_params_across_table_forms.rb +64 -0
- data/app/overrides/spree/admin/promotion_rules/_promotion_rule/set_turbo_frame_on_edit_link.html.erb.deface +11 -0
- data/app/overrides/spree/admin/stores/add_special_instruction_message_settings.rb +17 -0
- data/app/overrides/use_absolute_time_for_order_header_completed_at.rb +15 -0
- data/app/presenters/spree/mega_nav_presenter_decorator.rb +26 -0
- data/app/services/ba_spree/setup_service.rb +144 -0
- data/app/services/spree/assets/position_normalizer.rb +67 -0
- data/app/services/spree/products/duplicator_decorator.rb +39 -0
- data/app/services/spree/products/prepare_nested_attributes_decorator.rb +58 -0
- data/app/views/spree/addresses/edit.html.erb +64 -0
- data/app/views/spree/admin/orders/_order.html.erb +32 -0
- data/app/views/spree/admin/orders/_summary.html.erb +122 -0
- data/app/views/spree/admin/shared/_custom_head.html.erb +56 -0
- data/app/views/spree/admin/shared/sidebar/_promotions_nav.html.erb +55 -0
- data/app/views/spree/admin/stores/_special_instruction_message_settings.html.erb +14 -0
- data/app/views/spree/checkout/_accept_marketing_checkbox.html.erb +40 -0
- data/app/views/spree/checkout/_address.html.erb +135 -0
- data/app/views/spree/checkout/_confirm.html.erb +25 -0
- data/app/views/spree/checkout/_line_item.html.erb +26 -0
- data/app/views/spree/checkout/_payment_method_confirm.html.erb +8 -0
- data/app/views/spree/checkout/_payment_methods.html.erb +88 -0
- data/app/views/spree/checkout/_summary.html.erb +146 -0
- data/app/views/spree/shared/_purchased_items_table.html.erb +60 -0
- data/app/views/spree/shared/_purchased_items_table.text.erb +24 -0
- data/app/views/themes/default/spree/orders/_empty.html.erb +14 -0
- data/app/views/themes/default/spree/orders/_summary.html.erb +87 -0
- data/app/views/themes/default/spree/orders/edit.turbo_stream.erb +10 -0
- data/app/views/themes/default/spree/page_sections/_featured_taxons.html.erb +38 -0
- data/app/views/themes/default/spree/page_sections/_header.html.erb +124 -0
- data/app/views/themes/default/spree/page_sections/_newsletter.html.erb +4 -0
- data/app/views/themes/default/spree/page_sections/_post_details.html.erb +22 -0
- data/app/views/themes/default/spree/page_sections/_product_details.html.erb +88 -0
- data/app/views/themes/default/spree/page_sections/_product_grid.html.erb +58 -0
- data/app/views/themes/default/spree/page_sections/nav/_desktop.html.erb +94 -0
- data/app/views/themes/default/spree/page_sections/nav/_mobile.html.erb +148 -0
- data/app/views/themes/default/spree/posts/_post.html.erb +16 -0
- data/app/views/themes/default/spree/products/_description.html.erb +7 -0
- data/app/views/themes/default/spree/products/_details.html.erb +65 -0
- data/app/views/themes/default/spree/products/_json_ld_variant.html.erb +15 -0
- data/app/views/themes/default/spree/products/_price.html.erb +76 -0
- data/app/views/themes/default/spree/products/_swiper.html.erb +90 -0
- data/app/views/themes/default/spree/shared/_account_pane.html.erb +18 -0
- data/app/views/themes/default/spree/shared/_cart_icon.html.erb +19 -0
- data/app/views/themes/default/spree/shared/_line_item_options.html.erb +9 -0
- data/app/views/themes/default/spree/shared/_logo.html.erb +39 -0
- data/app/views/themes/default/spree/shared/_nav_taxon_square_image.html.erb +8 -0
- data/app/views/themes/default/spree/shared/_order_shipment.html.erb +79 -0
- data/app/views/themes/default/spree/shared/_search.html.erb +33 -0
- data/app/views/themes/default/spree/shared/icons/_cart.html.erb +6 -0
- data/app/views/themes/default/spree/shared/icons/_cart_48.html.erb +8 -0
- data/app/views/themes/default/spree/shared/icons/_heart.html.erb +15 -0
- data/config/importmap.rb +12 -0
- data/config/initializers/admin_navigation.rb +43 -0
- data/config/initializers/spree.rb +6 -0
- data/config/initializers/spree_admin_label_i18n_alias.rb +52 -0
- data/config/initializers/spree_admin_tables.rb +57 -0
- data/config/initializers/spree_permitted_attributes.rb +1 -0
- data/config/locales/en.yml +15 -0
- data/config/locales/ja.yml +18 -0
- data/config/locales/spree.en.yml +4 -0
- data/config/locales/spree.ja.yml +3493 -0
- data/lib/ba_spree/engine.rb +41 -0
- data/lib/ba_spree/spree_events_dedup_patch.rb +44 -0
- data/lib/ba_spree/version.rb +3 -0
- data/lib/ba_spree.rb +20 -0
- data/lib/generators/ba_spree/install/install_generator.rb +85 -0
- 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
|
+
}
|